mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 01:15:18 +08:00
fix(pdf): route MinerU Cloud requests through the strict provider transport (#1688)
The MinerU Cloud parser issued its API-root, presigned-upload and result-ZIP requests with the default fetch, so a URL carried in a provider response could direct a further request at an arbitrary destination and redirects were followed without re-validation. Route every MinerU Cloud request through the strict provider transport — the redirect-validating, DNS-pinning helper already used by the audio providers, now re-exported under the neutral `providerFetch` name: - The configured API root runs under the operator's address policy (the ALLOW_LOCAL_NETWORKS opt-in applies), preserving self-hosted and BYOK reachability while keeping per-hop redirect re-validation and DNS pinning. - The response-supplied presigned upload URL always uses the strict public policy with redirects rejected outright: a 3xx answer is a hard failure and the body is never forwarded to a redirect target. - The response-supplied result ZIP URL uses the strict public policy and requires HTTPS on every followed redirect hop, via a new opt-in `requireHttps` transport policy that defaults off so audio behavior is unchanged. - Address-policy refusals are terminal and are not retried; the retry wrapper rethrows the original UnsafeNetworkTargetError when one is present. Bound the untrusted response bodies: - JSON control-plane responses are capped at 8 MiB and the result ZIP at 256 MiB, enforced from content-length and while streaming. - Before extraction the result archive is limited to 10,000 entries and to a 512 MiB declared uncompressed total: a cheap preflight over header fields that can understate the real payload. While decompressing, every text entry is capped at 64 MiB, every image at 32 MiB, and a 512 MiB running total is enforced from bytes counted as they stream out of the decompressor, so extraction aborts as soon as a limit is crossed instead of buffering the whole entry and checking afterwards. This also surfaces the decompressor's own size-mismatch error for entries whose declared size does not match their payload. Also classify the deprecated IPv4-compatible IPv6 range (::/96, excluding the unspecified `::` and loopback `::1`) by its embedded IPv4 in the shared SSRF guard, so `[::7f00:1]` and `[::a9fe:a9fe]` are refused at both the URL layer and the connect-time pinned lookup. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+287
-38
@@ -16,6 +16,12 @@ import {
|
||||
MINERU_IMAGE_MIMES,
|
||||
} from '@/lib/document/mime';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { providerFetch } from '@/lib/server/provider-fetch';
|
||||
import {
|
||||
findUnsafeNetworkTargetError,
|
||||
UnsafeNetworkTargetError,
|
||||
validateUrlForSSRFWithPolicy,
|
||||
} from '@/lib/server/ssrf-guard';
|
||||
|
||||
const log = createLogger('MinerUCloud');
|
||||
|
||||
@@ -26,6 +32,24 @@ const TIMEOUTS = {
|
||||
zip: 180_000,
|
||||
} as const;
|
||||
|
||||
// Hard cap on the result ZIP read. The largest accepted input is bounded by
|
||||
// MAX_EXTRACT_DOCUMENT_FILE_SIZE_BYTES (50 MiB); a parsed result bundles the
|
||||
// markdown, content list and extracted images, so the cap is set comfortably
|
||||
// above the widest legitimate result while still bounding the download.
|
||||
export const MAX_ZIP_BYTES = 256 * 1024 * 1024; // 256 MiB
|
||||
// JSON control-plane responses (batch creation / poll) only carry envelope
|
||||
// fields, so a small cap is enough and a runaway body cannot be buffered.
|
||||
export const MAX_JSON_BYTES = 8 * 1024 * 1024; // 8 MiB
|
||||
// Decompressed-result limits. The compressed archive is already capped by
|
||||
// MAX_ZIP_BYTES, but a small archive can still expand to a far larger payload,
|
||||
// so the entry count, the declared uncompressed total and the actual bytes read
|
||||
// while extracting are each bounded. Text entries are the markdown and content
|
||||
// list; every other extracted entry is treated as an image.
|
||||
export const MAX_ZIP_ENTRY_COUNT = 10_000;
|
||||
export const MAX_ZIP_UNCOMPRESSED_BYTES = 512 * 1024 * 1024; // 512 MiB
|
||||
export const MAX_ZIP_TEXT_ENTRY_BYTES = 64 * 1024 * 1024; // 64 MiB
|
||||
export const MAX_ZIP_IMAGE_ENTRY_BYTES = 32 * 1024 * 1024; // 32 MiB
|
||||
|
||||
const POLL_INTERVAL_MS = 2_500;
|
||||
const POLL_MAX_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
|
||||
@@ -53,6 +77,14 @@ function extToMime(ext: string): string {
|
||||
}
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
// An address-policy refusal is a deterministic decision about the target, not
|
||||
// a transient transport failure: retrying the same URL cannot change it.
|
||||
if (findUnsafeNetworkTargetError(err)) return false;
|
||||
// A refused redirect is likewise deterministic: the target answered 3xx and
|
||||
// the request was configured to reject that, so a retry re-issues the same
|
||||
// rejected request. Undici reports it as `TypeError: fetch failed` with an
|
||||
// `Error('unexpected redirect')` cause, which must not look retryable.
|
||||
if (isRedirectRefusal(err)) return false;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return ['fetch failed', 'econnreset', 'etimedout', 'timeout', 'aborted'].some((s) =>
|
||||
@@ -60,6 +92,19 @@ function isRetryable(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Follow the `cause` chain looking for undici's rejected-redirect error. */
|
||||
function isRedirectRefusal(err: unknown): boolean {
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = err;
|
||||
while (current && typeof current === 'object' && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const message = (current as { message?: unknown }).message;
|
||||
if (typeof message === 'string' && /unexpected redirect/i.test(message)) return true;
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fetchWithRetry<T>(fn: () => Promise<T>, context: string, attempts = 4): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
@@ -72,8 +117,13 @@ async function fetchWithRetry<T>(fn: () => Promise<T>, context: string, attempts
|
||||
await sleep(400 * i);
|
||||
}
|
||||
}
|
||||
// Preserve an address-policy refusal as its original typed error so callers
|
||||
// can map it to a 403 instead of an opaque transport failure; every other
|
||||
// terminal error keeps the descriptive context message.
|
||||
const blocked = findUnsafeNetworkTargetError(lastErr);
|
||||
if (blocked) throw blocked;
|
||||
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
||||
throw new Error(`MinerU Cloud ${context} failed: ${msg}`);
|
||||
throw new Error(`MinerU Cloud ${context} failed: ${msg}`, { cause: lastErr });
|
||||
}
|
||||
|
||||
// ── API envelope ──────────────────────────────────────────────────────────────
|
||||
@@ -84,8 +134,46 @@ interface MinerUEnvelope<T = unknown> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ── Bounded response reads ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read a response body into a Buffer, refusing to buffer more than `maxBytes`.
|
||||
* A declared `content-length` over the cap is rejected before the body is
|
||||
* touched; the streamed path enforces the same cap chunk by chunk so a body
|
||||
* without a length (or with a lying one) still cannot exhaust memory.
|
||||
*/
|
||||
async function readBoundedBody(res: Response, maxBytes: number, context: string): Promise<Buffer> {
|
||||
const declared = Number(res.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new Error(`MinerU Cloud ${context}: response exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
const body = res.body;
|
||||
if (!body) return Buffer.alloc(0);
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`MinerU Cloud ${context}: response exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function readMinerUJson<T>(res: Response, context: string): Promise<T> {
|
||||
const text = await res.text();
|
||||
const text = (await readBoundedBody(res, MAX_JSON_BYTES, context)).toString('utf8');
|
||||
let json: MinerUEnvelope<T>;
|
||||
try {
|
||||
json = JSON.parse(text) as MinerUEnvelope<T>;
|
||||
@@ -105,6 +193,32 @@ async function readMinerUJson<T>(res: Response, context: string): Promise<T> {
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ── Response-URL policy ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Response-supplied MinerU URLs (the presigned upload URL and the result ZIP
|
||||
* URL) must be public HTTPS endpoints in every legitimate flow, whatever the
|
||||
* origin policy for the configured API root is. This checks the scheme and runs
|
||||
* the URL through the strict public address policy before any request; the
|
||||
* strict transport then re-validates redirect hops and pins connect-time DNS.
|
||||
*
|
||||
* Failures are thrown as {@link UnsafeNetworkTargetError} so the retry wrapper
|
||||
* treats them as terminal rather than a transient transport error.
|
||||
*/
|
||||
async function assertPublicHttpsResponseUrl(rawUrl: string, context: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`MinerU Cloud ${context}: provider response contained an invalid URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error(`MinerU Cloud ${context}: provider response URL must use https`);
|
||||
}
|
||||
const ssrfError = await validateUrlForSSRFWithPolicy(parsed.href, { allowLocalNetworks: false });
|
||||
if (ssrfError) throw new UnsafeNetworkTargetError(ssrfError);
|
||||
}
|
||||
|
||||
// ── Filename sanitization ─────────────────────────────────────────────────────
|
||||
|
||||
const MINERU_CLOUD_SUPPORTED_EXTENSIONS = new Set(getExtensionsForProviders(['mineru-cloud']));
|
||||
@@ -128,19 +242,74 @@ interface BatchExtractRow {
|
||||
err_msg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSZip 3.10 exposes `internalStream` at runtime but omits it from its bundled
|
||||
* type declarations, which only surface `async`/`nodeStream`. This is the
|
||||
* narrow slice of the stream-helper API the streaming reader relies on.
|
||||
*/
|
||||
interface StreamingZipEntry extends JSZip.JSZipObject {
|
||||
internalStream(type: 'uint8array'): JSZip.JSZipStreamHelper<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared uncompressed size for a loaded JSZip entry. JSZip exposes it only on
|
||||
* the internal `_data` object (`CompressedObject.uncompressedSize`), so it is
|
||||
* read defensively and treated as a hint only: a declared size can understate
|
||||
* the real payload, which is why the extracted length is checked too.
|
||||
*/
|
||||
function declaredUncompressedSize(entry: JSZip.JSZipObject): number | null {
|
||||
const data = (entry as { _data?: { uncompressedSize?: unknown } })._data;
|
||||
const size = data?.uncompressedSize;
|
||||
return typeof size === 'number' && Number.isFinite(size) ? size : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an archive before extraction when its entry count or the total
|
||||
* declared uncompressed size is beyond the configured budget. The declared
|
||||
* total is untrusted but cheap, and it bounds an archive that honestly declares
|
||||
* a very large payload.
|
||||
*/
|
||||
function assertZipEntryBudget(zip: JSZip): void {
|
||||
const paths = Object.keys(zip.files);
|
||||
if (paths.length > MAX_ZIP_ENTRY_COUNT) {
|
||||
throw new Error(
|
||||
`MinerU Cloud ZIP: ${paths.length} entries exceed the ${MAX_ZIP_ENTRY_COUNT}-entry limit`,
|
||||
);
|
||||
}
|
||||
let declaredTotal = 0;
|
||||
for (const path of paths) {
|
||||
const declared = declaredUncompressedSize(zip.files[path]);
|
||||
if (declared === null) continue;
|
||||
declaredTotal += declared;
|
||||
if (declaredTotal > MAX_ZIP_UNCOMPRESSED_BYTES) {
|
||||
throw new Error(
|
||||
`MinerU Cloud ZIP: declared uncompressed size exceeds the ${MAX_ZIP_UNCOMPRESSED_BYTES}-byte limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseMinerUZip(zipUrl: string): Promise<ParsedPdfContent> {
|
||||
await assertPublicHttpsResponseUrl(zipUrl, 'ZIP download');
|
||||
log.info('[MinerU Cloud] Downloading result ZIP...');
|
||||
|
||||
const zipRes = await fetchWithRetry(
|
||||
() => fetch(zipUrl, { signal: AbortSignal.timeout(TIMEOUTS.zip) }),
|
||||
() =>
|
||||
providerFetch(
|
||||
zipUrl,
|
||||
{ signal: AbortSignal.timeout(TIMEOUTS.zip) },
|
||||
{ allowLocalNetworks: false, requireHttps: true },
|
||||
),
|
||||
'ZIP download',
|
||||
);
|
||||
if (!zipRes.ok) {
|
||||
const text = await zipRes.text().catch(() => zipRes.statusText);
|
||||
const text = await readBoundedBody(zipRes, MAX_JSON_BYTES, 'ZIP download')
|
||||
.then((buf) => buf.toString('utf8'))
|
||||
.catch(() => zipRes.statusText);
|
||||
throw new Error(`MinerU Cloud ZIP download failed (${zipRes.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const zipBuf = Buffer.from(await zipRes.arrayBuffer());
|
||||
const zipBuf = await readBoundedBody(zipRes, MAX_ZIP_BYTES, 'ZIP download');
|
||||
let zip: Awaited<ReturnType<typeof JSZip.loadAsync>>;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(zipBuf);
|
||||
@@ -148,6 +317,8 @@ async function parseMinerUZip(zipUrl: string): Promise<ParsedPdfContent> {
|
||||
throw new Error(`MinerU Cloud ZIP parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
assertZipEntryBudget(zip);
|
||||
|
||||
const filePaths = Object.keys(zip.files).filter((p) => !zip.files[p].dir);
|
||||
const fullMdPath = filePaths.find((p) => /(^|\/)full\.md$/i.test(p));
|
||||
const contentListPath = filePaths.find(
|
||||
@@ -160,7 +331,60 @@ async function parseMinerUZip(zipUrl: string): Promise<ParsedPdfContent> {
|
||||
);
|
||||
}
|
||||
|
||||
const mdContent = await zip.file(fullMdPath)!.async('string');
|
||||
// Actual decompressed bytes read so far. The declared sizes above are only a
|
||||
// cheap pre-check; an archive can understate them, so each entry is measured
|
||||
// and the running total is bounded while it is streamed out of the
|
||||
// decompressor — the cap is enforced before the full entry is buffered.
|
||||
let extractedBytes = 0;
|
||||
async function readEntry(entry: JSZip.JSZipObject, kind: 'text' | 'image'): Promise<Buffer> {
|
||||
const cap = kind === 'text' ? MAX_ZIP_TEXT_ENTRY_BYTES : MAX_ZIP_IMAGE_ENTRY_BYTES;
|
||||
const stream = (entry as StreamingZipEntry).internalStream('uint8array');
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let entryBytes = 0;
|
||||
let done = false;
|
||||
stream
|
||||
.on('data', (chunk: Uint8Array) => {
|
||||
if (done) return;
|
||||
entryBytes += chunk.byteLength;
|
||||
if (entryBytes > cap) {
|
||||
done = true;
|
||||
stream.pause();
|
||||
reject(
|
||||
new Error(
|
||||
`MinerU Cloud ZIP: entry "${entry.name}" extracted ${entryBytes} bytes, over the ${cap}-byte ${kind} limit`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (extractedBytes + entryBytes > MAX_ZIP_UNCOMPRESSED_BYTES) {
|
||||
done = true;
|
||||
stream.pause();
|
||||
reject(
|
||||
new Error(
|
||||
`MinerU Cloud ZIP: extracted content exceeds the ${MAX_ZIP_UNCOMPRESSED_BYTES}-byte limit`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
})
|
||||
.on('error', (err: Error) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
reject(err);
|
||||
})
|
||||
.on('end', () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
extractedBytes += entryBytes;
|
||||
resolve(Buffer.concat(chunks));
|
||||
})
|
||||
.resume();
|
||||
});
|
||||
}
|
||||
|
||||
const mdContent = (await readEntry(zip.file(fullMdPath)!, 'text')).toString('utf8');
|
||||
const dirPrefix = fullMdPath.includes('/')
|
||||
? fullMdPath.slice(0, fullMdPath.lastIndexOf('/') + 1)
|
||||
: '';
|
||||
@@ -168,7 +392,7 @@ async function parseMinerUZip(zipUrl: string): Promise<ParsedPdfContent> {
|
||||
// Parse content_list.json if present
|
||||
let contentList: unknown;
|
||||
if (contentListPath) {
|
||||
const raw = await zip.file(contentListPath)!.async('string');
|
||||
const raw = (await readEntry(zip.file(contentListPath)!, 'text')).toString('utf8');
|
||||
try {
|
||||
contentList = JSON.parse(raw);
|
||||
} catch {
|
||||
@@ -182,7 +406,7 @@ async function parseMinerUZip(zipUrl: string): Promise<ParsedPdfContent> {
|
||||
for (const candidate of [dirPrefix + normalized, normalized]) {
|
||||
const entry = zip.file(candidate);
|
||||
if (!entry) continue;
|
||||
const buf = await entry.async('nodebuffer');
|
||||
const buf = await readEntry(entry, 'image');
|
||||
const ext = candidate.split('.').pop() ?? 'png';
|
||||
return `data:${extToMime(ext)};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
@@ -251,25 +475,37 @@ export async function parseWithMinerUCloud(
|
||||
const apiRoot = (config.baseUrl || MINERU_CLOUD_DEFAULT_BASE).replace(/\/+$/, '');
|
||||
const uploadFileName = sanitizeFileName(sourceFileName);
|
||||
|
||||
// The API root is a configured provider endpoint — a server-managed/default
|
||||
// endpoint or a self-hosted URL the operator opted into with
|
||||
// ALLOW_LOCAL_NETWORKS. It always runs under the operator policy (the
|
||||
// transport falls back to the env opt-in when `allowLocalNetworks` is
|
||||
// undefined); only the response-supplied upload and ZIP URLs are held to the
|
||||
// strict public policy.
|
||||
const firstHopPolicy = { allowLocalNetworks: undefined };
|
||||
|
||||
log.info(`[MinerU Cloud] Starting parse: ${uploadFileName} (${documentBuffer.byteLength} bytes)`);
|
||||
|
||||
// Step 1: Create batch — request presigned upload URL
|
||||
const batchData = await fetchWithRetry(async () => {
|
||||
const res = await fetch(`${apiRoot}/file-urls/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
const res = await providerFetch(
|
||||
`${apiRoot}/file-urls/batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: [{ name: uploadFileName }],
|
||||
enable_formula: true,
|
||||
enable_table: true,
|
||||
model_version: 'vlm',
|
||||
language: 'ch',
|
||||
}),
|
||||
signal: AbortSignal.timeout(TIMEOUTS.batch),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: [{ name: uploadFileName }],
|
||||
enable_formula: true,
|
||||
enable_table: true,
|
||||
model_version: 'vlm',
|
||||
language: 'ch',
|
||||
}),
|
||||
signal: AbortSignal.timeout(TIMEOUTS.batch),
|
||||
});
|
||||
firstHopPolicy,
|
||||
);
|
||||
return readMinerUJson<{ batch_id: string; file_urls?: string[]; files?: string[] }>(
|
||||
res,
|
||||
'file-urls/batch',
|
||||
@@ -284,24 +520,33 @@ export async function parseWithMinerUCloud(
|
||||
log.info(`[MinerU Cloud] Batch ${batchData.batch_id} created, uploading document...`);
|
||||
|
||||
// Step 2: Upload document to presigned URL
|
||||
await assertPublicHttpsResponseUrl(uploadUrls[0], 'presigned upload');
|
||||
const putRes = await fetchWithRetry(
|
||||
() =>
|
||||
fetch(uploadUrls[0], {
|
||||
method: 'PUT',
|
||||
body: new Blob([
|
||||
documentBuffer.buffer.slice(
|
||||
documentBuffer.byteOffset,
|
||||
documentBuffer.byteOffset + documentBuffer.byteLength,
|
||||
) as ArrayBuffer,
|
||||
]),
|
||||
signal: AbortSignal.timeout(TIMEOUTS.upload),
|
||||
// No Content-Type — presigned OSS URLs are sensitive to headers in the signature
|
||||
}),
|
||||
providerFetch(
|
||||
uploadUrls[0],
|
||||
{
|
||||
method: 'PUT',
|
||||
body: new Blob([
|
||||
documentBuffer.buffer.slice(
|
||||
documentBuffer.byteOffset,
|
||||
documentBuffer.byteOffset + documentBuffer.byteLength,
|
||||
) as ArrayBuffer,
|
||||
]),
|
||||
signal: AbortSignal.timeout(TIMEOUTS.upload),
|
||||
// No Content-Type — presigned OSS URLs are sensitive to headers in the signature
|
||||
},
|
||||
// A presigned URL identifies one exact destination: a 3xx answer is a
|
||||
// hard failure and must never be followed.
|
||||
{ allowLocalNetworks: false, rejectRedirects: true },
|
||||
),
|
||||
'presigned upload',
|
||||
5,
|
||||
);
|
||||
if (!putRes.ok) {
|
||||
const text = await putRes.text().catch(() => putRes.statusText);
|
||||
const text = await readBoundedBody(putRes, MAX_JSON_BYTES, 'presigned upload')
|
||||
.then((buf) => buf.toString('utf8'))
|
||||
.catch(() => putRes.statusText);
|
||||
throw new Error(`MinerU Cloud upload failed (${putRes.status}): ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
@@ -316,10 +561,14 @@ export async function parseWithMinerUCloud(
|
||||
while (Date.now() < deadline) {
|
||||
const statusData = await fetchWithRetry(
|
||||
async () => {
|
||||
const res = await fetch(`${apiRoot}/extract-results/batch/${batchData.batch_id}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUTS.poll),
|
||||
});
|
||||
const res = await providerFetch(
|
||||
`${apiRoot}/extract-results/batch/${batchData.batch_id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUTS.poll),
|
||||
},
|
||||
firstHopPolicy,
|
||||
);
|
||||
return readMinerUJson<{ extract_result?: BatchExtractRow | BatchExtractRow[] }>(
|
||||
res,
|
||||
'extract-results/batch',
|
||||
|
||||
@@ -72,6 +72,13 @@ export type AudioProviderFetchPolicy = Partial<SsrfValidationPolicy> & {
|
||||
* issued with the caller's `redirect: 'error'` semantics intact.
|
||||
*/
|
||||
rejectRedirects?: boolean;
|
||||
/**
|
||||
* When `true`, every followed redirect target must be HTTPS; a hop to `http:`
|
||||
* is refused as an address-policy refusal and is not retried. Off by default
|
||||
* so existing callers keep following HTTP hops; only redirect-following
|
||||
* requests are affected (`rejectRedirects` already refuses every hop).
|
||||
*/
|
||||
requireHttps?: boolean;
|
||||
};
|
||||
|
||||
/** A `fetch`-shaped provider transport bound to one address policy. */
|
||||
@@ -187,6 +194,7 @@ export async function audioProviderFetch(
|
||||
fetchImpl: undiciTransport,
|
||||
dispatcher,
|
||||
allowLocalNetworks,
|
||||
...(policy.requireHttps ? { requireHttps: true } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
// Undici reports a connect-time lookup refusal as `TypeError: fetch failed`
|
||||
|
||||
@@ -30,6 +30,13 @@ import {
|
||||
|
||||
export const MAX_REDIRECT_HOPS = 5;
|
||||
|
||||
/**
|
||||
* Refusal message when a hop target is not HTTPS. Callers that require TLS on
|
||||
* every hop (for example a provider-supplied download URL) get a guard-typed
|
||||
* refusal, not an opaque transport failure.
|
||||
*/
|
||||
export const REDIRECT_REQUIRES_HTTPS_MESSAGE = 'Provider redirect target must use https';
|
||||
|
||||
/** A `fetch`-shaped transport the per-hop loop issues requests through. */
|
||||
export type RedirectValidationFetch = (
|
||||
input: string | URL,
|
||||
@@ -47,6 +54,12 @@ export interface RedirectValidationOptions {
|
||||
* their current semantics.
|
||||
*/
|
||||
allowLocalNetworks?: boolean;
|
||||
/**
|
||||
* When `true`, every redirect target must be HTTPS; a hop to `http:` is
|
||||
* refused as an address-policy refusal. Defaults to `false`, so callers that
|
||||
* do not opt in keep following HTTP hops exactly as before.
|
||||
*/
|
||||
requireHttps?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +199,10 @@ export async function fetchWithRedirectValidation(
|
||||
throw new Error('Provider request received an invalid redirect Location');
|
||||
}
|
||||
|
||||
if (options.requireHttps && new URL(nextUrl).protocol !== 'https:') {
|
||||
throw new UnsafeNetworkTargetError(REDIRECT_REQUIRES_HTTPS_MESSAGE);
|
||||
}
|
||||
|
||||
const ssrfError = await validateUrlForSSRFWithPolicy(nextUrl, { allowLocalNetworks });
|
||||
if (ssrfError) throw new UnsafeNetworkTargetError(ssrfError);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Neutral name for the strict provider transport.
|
||||
*
|
||||
* The implementation lives in {@link ./audio-provider-fetch} because it began
|
||||
* with the TTS/ASR adapters, but it is not audio-specific: it validates every
|
||||
* redirect hop, pins connect-time DNS to vetted answers and normalizes request
|
||||
* bodies across the two undici copies. Non-audio providers (for example the
|
||||
* MinerU Cloud document parser) use the same transport under this name so
|
||||
* import sites do not read as if they were audio code.
|
||||
*
|
||||
* This module is a re-export only — the behavior is defined once, in the
|
||||
* original module, so audio and non-audio callers cannot drift.
|
||||
*/
|
||||
export {
|
||||
audioProviderFetch as providerFetch,
|
||||
createAudioProviderFetch as createProviderFetch,
|
||||
resolveAllowLocalNetworks,
|
||||
destroyAudioProviderDispatchersForTests,
|
||||
type AudioProviderFetchPolicy as ProviderFetchPolicy,
|
||||
type AudioProviderFetch as ProviderFetch,
|
||||
} from '@/lib/server/audio-provider-fetch';
|
||||
@@ -101,9 +101,11 @@ function canonicalizeIp(value: string): string | null {
|
||||
* IPv4 addresses carried inside an IPv6 literal by a transition mechanism:
|
||||
* 6to4 (2002::/16), Teredo (2001:0::/32, XOR-inverted), ISATAP interface
|
||||
* identifiers, NAT64 at the well-known (64:ff9b::/96) and RFC 8215 local-use
|
||||
* (64:ff9b:1::/48) prefixes, and the RFC 6145 IPv4-translatable prefix
|
||||
* (::ffff:0:0:0/96). Every NAT64/translation form embeds the IPv4 in the last
|
||||
* 32 bits. Empty when none applies.
|
||||
* (64:ff9b:1::/48) prefixes, the RFC 6145 IPv4-translatable prefix
|
||||
* (::ffff:0:0:0/96) and the deprecated IPv4-compatible range (::/96, excluding
|
||||
* the unspecified `::` and loopback `::1`, which are classified directly).
|
||||
* Every NAT64/translation form embeds the IPv4 in the last 32 bits. Empty when
|
||||
* none applies.
|
||||
*/
|
||||
function tunnelEmbeddedIPv4(normalized: string): string[] {
|
||||
const hextets = expandIPv6(normalized);
|
||||
@@ -134,6 +136,20 @@ function tunnelEmbeddedIPv4(normalized: string): string[] {
|
||||
) {
|
||||
embedded.push(dotted(hextets[6], hextets[7]));
|
||||
}
|
||||
// Deprecated IPv4-compatible form `::a.b.c.d` (RFC 4291 §2.5.5.1), which the
|
||||
// WHATWG URL parser canonicalizes to `::xxxx:xxxx`. `::` and `::1` are
|
||||
// excluded because they are classified directly as unspecified/loopback.
|
||||
if (
|
||||
hextets[0] === 0x0000 &&
|
||||
hextets[1] === 0x0000 &&
|
||||
hextets[2] === 0x0000 &&
|
||||
hextets[3] === 0x0000 &&
|
||||
hextets[4] === 0x0000 &&
|
||||
hextets[5] === 0x0000 &&
|
||||
!(hextets[6] === 0x0000 && (hextets[7] === 0x0000 || hextets[7] === 0x0001))
|
||||
) {
|
||||
embedded.push(dotted(hextets[6], hextets[7]));
|
||||
}
|
||||
return embedded;
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,32 @@ describe('POST /api/extract-document (asset-id form)', () => {
|
||||
expect(mocks.parseWithMinerUCloud).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a client-supplied local baseUrl when ALLOW_LOCAL_NETWORKS is unset', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('ALLOW_LOCAL_NETWORKS', undefined);
|
||||
mocks.resolveServerAsset.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
buffer: Buffer.from('%PDF-1.4'),
|
||||
mimeType: 'application/pdf',
|
||||
});
|
||||
|
||||
const res = await postExtractDocumentByAssetId({
|
||||
assetId: 'ast_abc',
|
||||
fileName: 'lesson.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
providerId: 'mineru-cloud',
|
||||
baseUrl: 'http://127.0.0.1:8000/v1/',
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(json).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'INVALID_URL',
|
||||
});
|
||||
expect(mocks.parseWithMinerUCloud).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the JSON path proceed when ALLOW_LOCAL_NETWORKS=true opts a local base URL in', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Operator-policy coverage for the MinerU Cloud API root, over the real
|
||||
* transport.
|
||||
*
|
||||
* The API root is a configured endpoint, so a self-hoster with
|
||||
* `ALLOW_LOCAL_NETWORKS=true` must be able to point it at a local MinerU
|
||||
* service. `mineru-cloud.test.ts` asserts the policy wiring with a stubbed
|
||||
* transport; this file drives the real redirect-validating, DNS-pinning
|
||||
* transport against a loopback mock, proving that:
|
||||
*
|
||||
* - with the operator opt-in enabled the first hop actually reaches the
|
||||
* client-supplied local API root, while a response-supplied private upload
|
||||
* URL is still refused under the strict public policy and never requested.
|
||||
*
|
||||
* The complementary "opt-in unset" case is a route-level decision: the route
|
||||
* rejects the local baseUrl with `validateUrlForSSRF` before the parser runs,
|
||||
* which `extract-document-route.test.ts` covers.
|
||||
*/
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { parseWithMinerUCloud } from '@/lib/pdf/mineru-cloud';
|
||||
import { destroyAudioProviderDispatchersForTests } from '@/lib/server/provider-fetch';
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockApiRoot {
|
||||
port: number;
|
||||
requests: string[];
|
||||
}
|
||||
|
||||
const servers: Server[] = [];
|
||||
const originalAllowLocal = process.env.ALLOW_LOCAL_NETWORKS;
|
||||
|
||||
/** A loopback MinerU v4 control plane that answers batch creation with `uploadUrl`. */
|
||||
async function startMockApiRoot(uploadUrl: string): Promise<MockApiRoot> {
|
||||
const requests: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
requests.push(`${req.method} ${req.url}`);
|
||||
req.resume();
|
||||
if (req.method === 'POST' && req.url?.endsWith('/file-urls/batch')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({ code: 0, msg: 'ok', data: { batch_id: 'b1', file_urls: [uploadUrl] } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 1, msg: 'not found', data: {} }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
return { port: (server.address() as AddressInfo).port, requests };
|
||||
}
|
||||
|
||||
describe('parseWithMinerUCloud — operator policy on a local API root', () => {
|
||||
beforeEach(() => {
|
||||
destroyAudioProviderDispatchersForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
destroyAudioProviderDispatchersForTests();
|
||||
if (originalAllowLocal === undefined) delete process.env.ALLOW_LOCAL_NETWORKS;
|
||||
else process.env.ALLOW_LOCAL_NETWORKS = originalAllowLocal;
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('reaches a local API root under ALLOW_LOCAL_NETWORKS=true but refuses its private upload URL', async () => {
|
||||
process.env.ALLOW_LOCAL_NETWORKS = 'true';
|
||||
const origin = await startMockApiRoot('https://10.1.2.3/upload/lesson.pdf');
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: `http://127.0.0.1:${origin.port}/api/v4`,
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/not allowed/);
|
||||
|
||||
// The first hop reached the loopback mock under the operator policy...
|
||||
expect(origin.requests).toEqual(['POST /api/v4/file-urls/batch']);
|
||||
// ...while the private response-supplied upload URL was never requested.
|
||||
expect(origin.requests.some((request) => request.includes('/upload'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Real-transport coverage for the MinerU Cloud second hop.
|
||||
*
|
||||
* `mineru-cloud.test.ts` stubs the transport to assert policy wiring at the
|
||||
* parser level. This file drives the actual strict transport against loopback
|
||||
* HTTP servers, so it proves the two properties the parser relies on:
|
||||
*
|
||||
* - the presigned PUT body (a platform `Blob` of the document bytes) arrives
|
||||
* byte-for-byte through undici's own `fetch` with an undici dispatcher, with
|
||||
* the same framing (no Content-Type, a correct Content-Length);
|
||||
* - a response-supplied URL that answers a redirect to a private or metadata
|
||||
* address is refused under the strict public policy and never followed;
|
||||
* - a 3xx on the presigned upload (rejected via `rejectRedirects`) is a hard
|
||||
* failure and the redirect target never receives the body; and
|
||||
* - a hostname that rebinds to loopback between the URL-layer guard and the
|
||||
* connect-time lookup is refused by the pinned dispatcher.
|
||||
*
|
||||
* The redirect origin is a loopback server because a genuinely public HTTPS
|
||||
* origin is not reachable from a hermetic test; the transport does not validate
|
||||
* the initial URL (the parser does that), so what is under test here is the
|
||||
* per-hop re-validation that runs on the answer.
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
providerFetch,
|
||||
destroyAudioProviderDispatchersForTests,
|
||||
} from '@/lib/server/provider-fetch';
|
||||
import { validateUrlForSSRFWithPolicy } from '@/lib/server/ssrf-guard';
|
||||
|
||||
const dnsMocks = vi.hoisted(() => ({
|
||||
// Used by the URL-layer guard (`node:dns` promises API).
|
||||
promisesLookup: vi.fn(),
|
||||
// Used by the pinned dispatcher's connect-time lookup (callback API).
|
||||
callbackLookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:dns', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:dns')>();
|
||||
return {
|
||||
...actual,
|
||||
lookup: (...args: unknown[]) => dnsMocks.callbackLookup(...args),
|
||||
promises: { ...actual.promises, lookup: dnsMocks.promisesLookup },
|
||||
};
|
||||
});
|
||||
|
||||
type Answer = { address: string; family: number };
|
||||
|
||||
const PUBLIC: Answer[] = [{ address: '93.184.216.34', family: 4 }];
|
||||
const LOOPBACK: Answer[] = [{ address: '127.0.0.1', family: 4 }];
|
||||
const PRIVATE_BLOCK_MESSAGE = 'Local/private network URLs are not allowed';
|
||||
|
||||
/** A callback-style `dns.lookup` stand-in that always returns `addresses`. */
|
||||
function answerWith(addresses: Answer[]) {
|
||||
return (
|
||||
_hostname: string,
|
||||
options: { all?: boolean },
|
||||
callback: (...args: unknown[]) => void,
|
||||
): void => {
|
||||
if (options?.all) {
|
||||
callback(null, addresses);
|
||||
} else {
|
||||
callback(null, addresses[0]!.address, addresses[0]!.family);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
interface LoopbackServer {
|
||||
port: number;
|
||||
requests: () => number;
|
||||
lastHeaders: () => IncomingMessage['headers'] | undefined;
|
||||
lastBody: () => Buffer | undefined;
|
||||
}
|
||||
|
||||
async function startLoopback(
|
||||
handler?: (req: IncomingMessage, res: ServerResponse) => void,
|
||||
): Promise<LoopbackServer> {
|
||||
let count = 0;
|
||||
let headers: IncomingMessage['headers'] | undefined;
|
||||
let body: Buffer | undefined;
|
||||
const server = createServer((req, res) => {
|
||||
count += 1;
|
||||
headers = req.headers;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
if (chunks.length > 0) body = Buffer.concat(chunks);
|
||||
if (handler) {
|
||||
handler(req, res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
return {
|
||||
port: (server.address() as AddressInfo).port,
|
||||
requests: () => count,
|
||||
lastHeaders: () => headers,
|
||||
lastBody: () => body,
|
||||
};
|
||||
}
|
||||
|
||||
const originalAllowLocal = process.env.ALLOW_LOCAL_NETWORKS;
|
||||
|
||||
describe('providerFetch — MinerU Cloud second-hop transport', () => {
|
||||
beforeEach(() => {
|
||||
dnsMocks.promisesLookup.mockReset();
|
||||
dnsMocks.callbackLookup.mockReset();
|
||||
delete process.env.ALLOW_LOCAL_NETWORKS;
|
||||
destroyAudioProviderDispatchersForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
destroyAudioProviderDispatchersForTests();
|
||||
if (originalAllowLocal === undefined) delete process.env.ALLOW_LOCAL_NETWORKS;
|
||||
else process.env.ALLOW_LOCAL_NETWORKS = originalAllowLocal;
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('delivers the presigned PUT body bytes intact with the same framing', async () => {
|
||||
const origin = await startLoopback();
|
||||
// Non-trivial binary payload so a truncation or text coercion is visible.
|
||||
const payload = Buffer.alloc(256 * 1024);
|
||||
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
|
||||
|
||||
const response = await providerFetch(
|
||||
`http://127.0.0.1:${origin.port}/upload/lesson.pdf`,
|
||||
{ method: 'PUT', body: new Blob([payload]) },
|
||||
{ allowLocalNetworks: true },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const received = origin.lastBody()!;
|
||||
expect(received.length).toBe(payload.length);
|
||||
expect(received.equals(payload)).toBe(true);
|
||||
// `new Blob([...])` carries no media type, so no Content-Type is sent, and
|
||||
// undici frames the body with its own Content-Length.
|
||||
expect(origin.lastHeaders()!['content-type']).toBeUndefined();
|
||||
expect(origin.lastHeaders()!['content-length']).toBe(String(payload.length));
|
||||
});
|
||||
|
||||
it('refuses a redirect from the second hop to a cloud metadata address and never follows it', async () => {
|
||||
const origin = await startLoopback((_req, res) => {
|
||||
res.writeHead(302, { Location: 'http://169.254.169.254/latest/meta-data/' });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
providerFetch(`http://127.0.0.1:${origin.port}/result.zip`, undefined, {
|
||||
allowLocalNetworks: false,
|
||||
}),
|
||||
).rejects.toThrow('Cloud instance metadata endpoints are never allowed');
|
||||
|
||||
expect(origin.requests()).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses a redirect from the second hop to a private address and never follows it', async () => {
|
||||
const internal = await startLoopback();
|
||||
const origin = await startLoopback((_req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${internal.port}/secret` });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
providerFetch(`http://127.0.0.1:${origin.port}/result.zip`, undefined, {
|
||||
allowLocalNetworks: false,
|
||||
}),
|
||||
).rejects.toThrow(/not allowed/);
|
||||
|
||||
expect(origin.requests()).toBe(1);
|
||||
expect(internal.requests()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([301, 302, 303, 307, 308])(
|
||||
'refuses a %i redirect on the presigned upload PUT and never sends the body to the target',
|
||||
async (status) => {
|
||||
const target = await startLoopback();
|
||||
const origin = await startLoopback((_req, res) => {
|
||||
res.writeHead(status, { Location: `http://127.0.0.1:${target.port}/sink` });
|
||||
res.end();
|
||||
});
|
||||
const payload = Buffer.from('presigned-document-bytes');
|
||||
|
||||
// The operator opt-in makes the loopback target otherwise followable, so
|
||||
// this isolates `rejectRedirects` as the guard that stops the hop. The
|
||||
// parser wires the upload with `allowLocalNetworks: false` on top, which
|
||||
// is asserted separately with the stubbed transport.
|
||||
await expect(
|
||||
providerFetch(
|
||||
`http://127.0.0.1:${origin.port}/upload/lesson.pdf`,
|
||||
{ method: 'PUT', body: new Blob([payload]) },
|
||||
{ allowLocalNetworks: true, rejectRedirects: true },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// The origin served the upload once and answered 3xx; the redirect target
|
||||
// never received a request (and therefore never the body).
|
||||
expect(origin.requests()).toBe(1);
|
||||
expect(target.requests()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses a hostname that rebinds to loopback between guard and connect for the result ZIP', async () => {
|
||||
const trap = await startLoopback();
|
||||
const url = `http://rebind.test:${trap.port}/result.zip`;
|
||||
|
||||
// The URL-layer guard sees a public answer and passes...
|
||||
dnsMocks.promisesLookup.mockResolvedValue(PUBLIC);
|
||||
await expect(
|
||||
validateUrlForSSRFWithPolicy(url, { allowLocalNetworks: false }),
|
||||
).resolves.toBeNull();
|
||||
|
||||
// ...but the connect-time lookup is offered loopback instead.
|
||||
dnsMocks.callbackLookup.mockImplementation(answerWith(LOOPBACK));
|
||||
|
||||
// The ZIP request's policy (`requireHttps` only constrains redirect hops;
|
||||
// the guard/connect split below is what refuses this request).
|
||||
await expect(
|
||||
providerFetch(url, undefined, { allowLocalNetworks: false, requireHttps: true }),
|
||||
).rejects.toThrow(PRIVATE_BLOCK_MESSAGE);
|
||||
|
||||
// A transport that ignored the pinned dispatcher would have connected here.
|
||||
expect(trap.requests()).toBe(0);
|
||||
expect(dnsMocks.callbackLookup).toHaveBeenCalledWith(
|
||||
'rebind.test',
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
+620
-104
@@ -1,6 +1,27 @@
|
||||
/**
|
||||
* MinerU Cloud request hardening, driven through a controllable transport.
|
||||
*
|
||||
* `parseWithMinerUCloud` now routes every request through the strict provider
|
||||
* transport (`providerFetch`) and validates the response-supplied presigned
|
||||
* upload / result-ZIP URLs before use. These tests stub that transport so they
|
||||
* can assert:
|
||||
*
|
||||
* - the address policy passed for the config-derived API root (always the
|
||||
* operator policy, so ALLOW_LOCAL_NETWORKS applies) and for the
|
||||
* response-supplied URLs (always strict public);
|
||||
* - that a response URL pointing at a private/metadata address, or using a
|
||||
* non-HTTPS scheme, is refused before the transport is ever called;
|
||||
* - that an address-policy refusal is not retried; and
|
||||
* - the read caps on JSON and ZIP responses.
|
||||
*
|
||||
* Real-transport behavior (pinned DNS, redirect-hop validation, body bytes on
|
||||
* the wire) is covered in `mineru-cloud-transport.test.ts`.
|
||||
*/
|
||||
import zlib from 'node:zlib';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseWithMinerUCloud } from '@/lib/pdf/mineru-cloud';
|
||||
|
||||
const transport = vi.hoisted(() => ({ providerFetch: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -11,151 +32,646 @@ vi.mock('@/lib/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MinerU Cloud document upload', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
// Keep every other export of the audio transport real (notably
|
||||
// `resolveAllowLocalNetworks`) and only replace the request function, which is
|
||||
// what `providerFetch` re-exports.
|
||||
vi.mock('@/lib/server/audio-provider-fetch', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/server/audio-provider-fetch')>();
|
||||
return { ...actual, audioProviderFetch: transport.providerFetch };
|
||||
});
|
||||
|
||||
it('preserves supported Office filename extensions for Cloud type inference', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('full.md', '# Parsed lesson');
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const batchBodies: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
import {
|
||||
parseWithMinerUCloud,
|
||||
MAX_JSON_BYTES,
|
||||
MAX_ZIP_BYTES,
|
||||
MAX_ZIP_ENTRY_COUNT,
|
||||
MAX_ZIP_TEXT_ENTRY_BYTES,
|
||||
MAX_ZIP_UNCOMPRESSED_BYTES,
|
||||
} from '@/lib/pdf/mineru-cloud';
|
||||
import { resolveAllowLocalNetworks } from '@/lib/server/provider-fetch';
|
||||
|
||||
const PUBLIC_UPLOAD = 'https://93.184.216.34/upload/lesson.pdf';
|
||||
const PUBLIC_ZIP = 'https://93.184.216.34/result.zip';
|
||||
|
||||
type TransportCall = [string | URL, RequestInit | undefined, { allowLocalNetworks?: boolean }];
|
||||
|
||||
function calls(): TransportCall[] {
|
||||
return transport.providerFetch.mock.calls as unknown as TransportCall[];
|
||||
}
|
||||
|
||||
function callUrls(): string[] {
|
||||
return calls().map(([input]) => String(input));
|
||||
}
|
||||
|
||||
function policyFor(substring: string): { allowLocalNetworks?: boolean } | undefined {
|
||||
const call = calls().find(([input]) => String(input).includes(substring));
|
||||
return call?.[2];
|
||||
}
|
||||
|
||||
async function makeZip(files: Record<string, string>): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
for (const [name, content] of Object.entries(files)) zip.file(name, content);
|
||||
return zip.generateAsync({ type: 'nodebuffer' });
|
||||
}
|
||||
|
||||
interface MinerUResponses {
|
||||
uploadUrl?: string;
|
||||
zipUrl?: string;
|
||||
zipBody?: Buffer | Response;
|
||||
/** Reply for the ZIP request instead of the default successful body. */
|
||||
onZip?: () => Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a transport that answers the MinerU v4 control plane and, by default,
|
||||
* a valid upload + ZIP download. Individual tests override one URL or reply.
|
||||
*/
|
||||
function installMinerU(overrides: MinerUResponses = {}) {
|
||||
const uploadUrl = overrides.uploadUrl ?? PUBLIC_UPLOAD;
|
||||
const zipUrl = overrides.zipUrl ?? PUBLIC_ZIP;
|
||||
return transport.providerFetch.mockImplementation(
|
||||
async (input: string | URL): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/file-urls/batch')) {
|
||||
batchBodies.push(JSON.parse(String(init?.body)));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
batch_id: 'batch-1',
|
||||
file_urls: ['https://upload.example/lesson.docx'],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ code: 0, msg: 'ok', data: { batch_id: 'b1', file_urls: [uploadUrl] } }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === 'https://upload.example/lesson.docx') {
|
||||
if (url === uploadUrl) {
|
||||
return new Response('', { status: 200 });
|
||||
}
|
||||
if (url.endsWith('/extract-results/batch/batch-1')) {
|
||||
if (url.endsWith('/extract-results/batch/b1')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
extract_result: {
|
||||
file_name: 'lesson.docx',
|
||||
state: 'done',
|
||||
full_zip_url: 'https://download.example/result.zip',
|
||||
},
|
||||
extract_result: { file_name: 'lesson.pdf', state: 'done', full_zip_url: zipUrl },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === 'https://download.example/result.zip') {
|
||||
if (url === zipUrl) {
|
||||
if (overrides.onZip) return overrides.onZip();
|
||||
const provided = overrides.zipBody;
|
||||
if (provided instanceof Response) return provided;
|
||||
const zipBody = provided ?? (await makeZip({ 'full.md': '# Hardened lesson' }));
|
||||
return new Response(new Uint8Array(zipBody), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected transport URL: ${url}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** A response whose body streams a single oversized chunk without allocating it. */
|
||||
function oversizedStreamResponse(byteLength: number, headers?: HeadersInit): Response {
|
||||
const fakeChunk = { byteLength } as unknown as Uint8Array;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(fakeChunk);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the first central-directory entry's declared uncompressed size so the
|
||||
* declared-size budget can be exercised without building a huge archive. JSZip
|
||||
* reads that field from the central directory when loading.
|
||||
*/
|
||||
function patchFirstEntryDeclaredSize(zipBuf: Buffer, declared: number): Buffer {
|
||||
const patched = Buffer.from(zipBuf);
|
||||
const central = patched.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
|
||||
if (central < 0) throw new Error('central directory not found');
|
||||
patched.writeUInt32LE(declared, central + 24);
|
||||
return patched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single-entry DEFLATE ZIP by hand so both the local header and the
|
||||
* central directory can declare an uncompressed size unrelated to the real
|
||||
* raw-deflate payload. JSZip reads the declared size from the central
|
||||
* directory and only detects the lie once the entry is fully decompressed; the
|
||||
* streaming reader is meant to stop before that point.
|
||||
*/
|
||||
function makeSingleEntryZip(
|
||||
name: string,
|
||||
rawDeflated: Buffer,
|
||||
declaredUncompressedSize: number,
|
||||
): Buffer {
|
||||
const nameBuf = Buffer.from(name, 'utf8');
|
||||
const compressedSize = rawDeflated.length;
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0); // local file header signature
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0, 6); // general purpose flags
|
||||
local.writeUInt16LE(8, 8); // compression method: DEFLATE
|
||||
local.writeUInt16LE(0, 10); // mod time
|
||||
local.writeUInt16LE(0, 12); // mod date
|
||||
local.writeUInt32LE(0, 14); // crc32 (loadAsync does not verify by default)
|
||||
local.writeUInt32LE(compressedSize, 18);
|
||||
local.writeUInt32LE(declaredUncompressedSize, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra field length
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0); // central directory signature
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed
|
||||
central.writeUInt16LE(0, 8); // general purpose flags
|
||||
central.writeUInt16LE(8, 10); // compression method: DEFLATE
|
||||
central.writeUInt16LE(0, 12); // mod time
|
||||
central.writeUInt16LE(0, 14); // mod date
|
||||
central.writeUInt32LE(0, 16); // crc32
|
||||
central.writeUInt32LE(compressedSize, 20);
|
||||
central.writeUInt32LE(declaredUncompressedSize, 24);
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt16LE(0, 30); // extra field length
|
||||
central.writeUInt16LE(0, 32); // comment length
|
||||
central.writeUInt16LE(0, 34); // disk number start
|
||||
central.writeUInt16LE(0, 36); // internal attributes
|
||||
central.writeUInt32LE(0, 38); // external attributes
|
||||
central.writeUInt32LE(0, 42); // local header offset
|
||||
|
||||
const localPart = Buffer.concat([local, nameBuf, rawDeflated]);
|
||||
const centralPart = Buffer.concat([central, nameBuf]);
|
||||
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0); // end of central directory signature
|
||||
end.writeUInt16LE(0, 4); // disk number
|
||||
end.writeUInt16LE(0, 6); // disk with central directory
|
||||
end.writeUInt16LE(1, 8); // entries on this disk
|
||||
end.writeUInt16LE(1, 10); // total entries
|
||||
end.writeUInt32LE(centralPart.length, 12);
|
||||
end.writeUInt32LE(localPart.length, 16);
|
||||
end.writeUInt16LE(0, 20); // comment length
|
||||
|
||||
return Buffer.concat([localPart, centralPart, end]);
|
||||
}
|
||||
|
||||
const originalAllowLocal = process.env.ALLOW_LOCAL_NETWORKS;
|
||||
|
||||
describe('parseWithMinerUCloud — transport policy and response URL validation', () => {
|
||||
afterEach(() => {
|
||||
transport.providerFetch.mockReset();
|
||||
if (originalAllowLocal === undefined) delete process.env.ALLOW_LOCAL_NETWORKS;
|
||||
else process.env.ALLOW_LOCAL_NETWORKS = originalAllowLocal;
|
||||
});
|
||||
|
||||
it('uploads the exact document bytes and parses the result ZIP', async () => {
|
||||
const zip = await makeZip({ 'full.md': '# Parsed lesson', 'content_list.json': '[]' });
|
||||
let uploaded: Buffer | undefined;
|
||||
let uploadInit: RequestInit | undefined;
|
||||
transport.providerFetch.mockImplementation(async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/file-urls/batch')) {
|
||||
return new Response(
|
||||
zipBuffer.buffer.slice(
|
||||
zipBuffer.byteOffset,
|
||||
zipBuffer.byteOffset + zipBuffer.byteLength,
|
||||
) as ArrayBuffer,
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: { batch_id: 'b1', file_urls: [PUBLIC_UPLOAD] },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
if (url === PUBLIC_UPLOAD) {
|
||||
uploadInit = init;
|
||||
uploaded = Buffer.from(await (init!.body as Blob).arrayBuffer());
|
||||
return new Response('', { status: 200 });
|
||||
}
|
||||
if (url.endsWith('/extract-results/batch/b1')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
extract_result: { file_name: 'lesson.pdf', state: 'done', full_zip_url: PUBLIC_ZIP },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === PUBLIC_ZIP) return new Response(new Uint8Array(zip), { status: 200 });
|
||||
throw new Error(`Unexpected transport URL: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const documentBuffer = Buffer.from('exact document bytes \u0000\u0001', 'latin1');
|
||||
const result = await parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('docx bytes'),
|
||||
'lesson.docx',
|
||||
documentBuffer,
|
||||
'lesson.pdf',
|
||||
);
|
||||
|
||||
expect(result.text).toContain('Parsed lesson');
|
||||
expect(result.metadata?.parser).toBe('mineru-cloud');
|
||||
expect(batchBodies).toEqual([
|
||||
expect.objectContaining({
|
||||
files: [{ name: 'lesson.docx' }],
|
||||
}),
|
||||
]);
|
||||
expect(uploaded?.equals(documentBuffer)).toBe(true);
|
||||
// Unchanged upload shape: PUT, no Content-Type header (presigned URLs are
|
||||
// header-sensitive), a Blob body and a timeout signal.
|
||||
expect(uploadInit?.method).toBe('PUT');
|
||||
expect(new Headers(uploadInit?.headers).has('content-type')).toBe(false);
|
||||
expect(uploadInit?.body).toBeInstanceOf(Blob);
|
||||
expect(uploadInit?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('preserves legacy Office filenames (cloud accepts .doc/.ppt/.xls)', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('full.md', '# Parsed legacy lesson');
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const batchBodies: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/file-urls/batch')) {
|
||||
batchBodies.push(JSON.parse(String(init?.body)));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
batch_id: 'batch-legacy',
|
||||
file_urls: ['https://upload.example/legacy.doc'],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === 'https://upload.example/legacy.doc') {
|
||||
return new Response('', { status: 200 });
|
||||
}
|
||||
if (url.endsWith('/extract-results/batch/batch-legacy')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
extract_result: {
|
||||
file_name: 'legacy.doc',
|
||||
state: 'done',
|
||||
full_zip_url: 'https://download.example/legacy.zip',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === 'https://download.example/legacy.zip') {
|
||||
return new Response(
|
||||
zipBuffer.buffer.slice(
|
||||
zipBuffer.byteOffset,
|
||||
zipBuffer.byteOffset + zipBuffer.byteLength,
|
||||
) as ArrayBuffer,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await parseWithMinerUCloud(
|
||||
it('keeps a client-supplied local API root on the operator policy while response URLs stay strict', async () => {
|
||||
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
|
||||
installMinerU();
|
||||
await parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
// A self-hoster's local MinerU service: reachable under the operator
|
||||
// opt-in, unlike the response-supplied URLs asserted below.
|
||||
baseUrl: 'http://127.0.0.1:8000/api/v4',
|
||||
},
|
||||
Buffer.from('doc bytes'),
|
||||
'legacy.doc',
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
);
|
||||
|
||||
expect(result.text).toContain('Parsed legacy lesson');
|
||||
expect(batchBodies).toEqual([
|
||||
expect.objectContaining({
|
||||
files: [{ name: 'legacy.doc' }],
|
||||
}),
|
||||
]);
|
||||
const firstHop = policyFor('/file-urls/batch');
|
||||
expect(firstHop).toBeDefined();
|
||||
// `allowLocalNetworks` is present but explicitly `undefined`, so the
|
||||
// transport falls back to the operator opt-in (enabled here) rather than
|
||||
// forcing the strict public policy.
|
||||
expect('allowLocalNetworks' in firstHop!).toBe(true);
|
||||
expect(resolveAllowLocalNetworks(firstHop!.allowLocalNetworks)).toBe(true);
|
||||
expect(
|
||||
resolveAllowLocalNetworks(policyFor('/extract-results/batch/b1')?.allowLocalNetworks),
|
||||
).toBe(true);
|
||||
// Response-supplied URLs never inherit the opt-in, and each carries its
|
||||
// own stricter transport mode: the upload rejects every redirect, the ZIP
|
||||
// requires HTTPS on every hop.
|
||||
expect(policyFor(PUBLIC_UPLOAD)).toMatchObject({
|
||||
allowLocalNetworks: false,
|
||||
rejectRedirects: true,
|
||||
});
|
||||
expect(policyFor(PUBLIC_ZIP)).toMatchObject({
|
||||
allowLocalNetworks: false,
|
||||
requireHttps: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('still refuses a private response-supplied ZIP URL with ALLOW_LOCAL_NETWORKS=true', async () => {
|
||||
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
|
||||
const zipUrl = 'https://127.0.0.1/result.zip';
|
||||
installMinerU({ zipUrl });
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'http://127.0.0.1:8000/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/not allowed/);
|
||||
|
||||
// batch + upload + poll, but never the refused private ZIP URL.
|
||||
expect(callUrls()).not.toContain(zipUrl);
|
||||
expect(callUrls()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWithMinerUCloud — response-supplied URL rejection', () => {
|
||||
afterEach(() => {
|
||||
transport.providerFetch.mockReset();
|
||||
});
|
||||
|
||||
const privateUrls: Array<[string, string]> = [
|
||||
['loopback', 'https://127.0.0.1/result.zip'],
|
||||
['RFC1918', 'https://10.0.0.5/result.zip'],
|
||||
['cloud metadata', 'https://169.254.169.254/latest/meta-data/'],
|
||||
['cloud metadata (Aliyun)', 'https://100.100.100.200/result.zip'],
|
||||
];
|
||||
|
||||
it.each(privateUrls)(
|
||||
'refuses a response-supplied ZIP URL on a %s address and does not fetch it',
|
||||
async (_label, zipUrl) => {
|
||||
installMinerU({ zipUrl });
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/(not|never) allowed/);
|
||||
|
||||
// batch + upload + poll, but never the refused ZIP URL.
|
||||
expect(callUrls()).not.toContain(zipUrl);
|
||||
expect(callUrls()).toHaveLength(3);
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses a response-supplied upload URL on a private address and does not fetch it', async () => {
|
||||
const uploadUrl = 'https://10.1.2.3/upload/lesson.pdf';
|
||||
installMinerU({ uploadUrl });
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/(not|never) allowed/);
|
||||
|
||||
// Only the batch request; the refused upload URL is never requested.
|
||||
expect(callUrls()).toEqual([expect.stringContaining('/file-urls/batch')]);
|
||||
});
|
||||
|
||||
it('refuses a non-HTTPS response-supplied URL', async () => {
|
||||
installMinerU({ zipUrl: 'http://93.184.216.34/result.zip' });
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/must use https/);
|
||||
|
||||
expect(callUrls()).not.toContain('http://93.184.216.34/result.zip');
|
||||
});
|
||||
|
||||
it('does not retry an address-policy refusal from the transport', async () => {
|
||||
const { UnsafeNetworkTargetError } = await import('@/lib/server/ssrf-guard');
|
||||
installMinerU();
|
||||
transport.providerFetch.mockImplementation(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/file-urls/batch')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: { batch_id: 'b1', file_urls: [PUBLIC_UPLOAD] },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === PUBLIC_UPLOAD) {
|
||||
// Undici surfaces a connect-time refusal as `TypeError: fetch failed`
|
||||
// with the policy error as `cause`. The retry wrapper must look through
|
||||
// the wrapper rather than retry the transport-shaped message.
|
||||
const wrapped = new TypeError('fetch failed');
|
||||
(wrapped as { cause?: unknown }).cause = new UnsafeNetworkTargetError(
|
||||
'Local/private/reserved network URLs are not allowed',
|
||||
);
|
||||
throw wrapped;
|
||||
}
|
||||
throw new Error(`Unexpected transport URL: ${url}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnsafeNetworkTargetError);
|
||||
|
||||
// Exactly one attempt at the upload: a policy refusal is terminal even when
|
||||
// its wrapper message would otherwise look retryable.
|
||||
expect(callUrls().filter((url) => url === PUBLIC_UPLOAD)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('surfaces a refused result ZIP URL as an UnsafeNetworkTargetError', async () => {
|
||||
const { UnsafeNetworkTargetError } = await import('@/lib/server/ssrf-guard');
|
||||
installMinerU({
|
||||
onZip: () => {
|
||||
// Undici surfaces a connect-time refusal as `TypeError: fetch failed`
|
||||
// with the policy error as `cause`; the retry wrapper must rethrow the
|
||||
// original typed refusal instead of wrapping it in a message string.
|
||||
const wrapped = new TypeError('fetch failed');
|
||||
(wrapped as { cause?: unknown }).cause = new UnsafeNetworkTargetError(
|
||||
'Local/private/reserved network URLs are not allowed',
|
||||
);
|
||||
throw wrapped;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnsafeNetworkTargetError);
|
||||
|
||||
// The refusal is terminal: the ZIP URL is requested exactly once.
|
||||
expect(callUrls().filter((url) => url === PUBLIC_ZIP)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not retry a rejected redirect on the presigned upload', async () => {
|
||||
installMinerU();
|
||||
let uploadAttempts = 0;
|
||||
transport.providerFetch.mockImplementation(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/file-urls/batch')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: { batch_id: 'b1', file_urls: [PUBLIC_UPLOAD] },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === PUBLIC_UPLOAD) {
|
||||
uploadAttempts += 1;
|
||||
// Undici's `redirect: 'error'` reports a 3xx this way; it must be
|
||||
// terminal even though the wrapper message looks transport-shaped.
|
||||
const wrapped = new TypeError('fetch failed');
|
||||
(wrapped as { cause?: unknown }).cause = new Error('unexpected redirect');
|
||||
throw wrapped;
|
||||
}
|
||||
throw new Error(`Unexpected transport URL: ${url}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/presigned upload failed/);
|
||||
|
||||
expect(uploadAttempts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWithMinerUCloud — bounded reads', () => {
|
||||
afterEach(() => {
|
||||
transport.providerFetch.mockReset();
|
||||
});
|
||||
|
||||
async function runWithZipResponse(zipResponse: Response): Promise<PromiseSettledResult<unknown>> {
|
||||
installMinerU({ onZip: () => zipResponse });
|
||||
return parseWithMinerUCloud(
|
||||
{ providerId: 'mineru-cloud', apiKey: 'cloud-key', baseUrl: 'https://mineru.example/api/v4' },
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
).then(
|
||||
(value) => ({ status: 'fulfilled', value }) as PromiseFulfilledResult<unknown>,
|
||||
(reason) => ({ status: 'rejected', reason }) as PromiseRejectedResult,
|
||||
);
|
||||
}
|
||||
|
||||
it('rejects a ZIP whose declared content-length exceeds the cap', async () => {
|
||||
const result = await runWithZipResponse(
|
||||
oversizedStreamResponse(0, { 'content-length': String(MAX_ZIP_BYTES + 1) }),
|
||||
);
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(String((result as PromiseRejectedResult).reason)).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('rejects a streamed ZIP that exceeds the cap without a content-length', async () => {
|
||||
const result = await runWithZipResponse(oversizedStreamResponse(MAX_ZIP_BYTES + 1));
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(String((result as PromiseRejectedResult).reason)).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('rejects a ZIP whose full.md expands beyond the per-entry text limit', async () => {
|
||||
// A small, compressed archive whose single entry expands beyond the cap;
|
||||
// the declared total is under the archive budget, so the extracted length
|
||||
// is what has to reject it.
|
||||
const zip = new JSZip();
|
||||
zip.file('full.md', 'a'.repeat(MAX_ZIP_TEXT_ENTRY_BYTES + 1));
|
||||
const zipBuf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
||||
expect(zipBuf.length).toBeLessThan(MAX_ZIP_BYTES);
|
||||
|
||||
const result = await runWithZipResponse(new Response(new Uint8Array(zipBuf), { status: 200 }));
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(String((result as PromiseRejectedResult).reason)).toContain('text limit');
|
||||
});
|
||||
|
||||
it('stops streaming a lying full.md before JSZip would report its size mismatch', async () => {
|
||||
// The raw-deflate stream expands to 256 MiB (four times the text cap) but
|
||||
// both headers declare 10 bytes. Streaming extraction must trip the
|
||||
// per-entry cap while decompressing; buffering the entry first would only
|
||||
// fail at the very end with JSZip's "uncompressed data size mismatch".
|
||||
const expansionBytes = MAX_ZIP_UNCOMPRESSED_BYTES / 2; // 256 MiB
|
||||
const rawDeflated = zlib.deflateRawSync(Buffer.alloc(expansionBytes, 0x61), { level: 6 });
|
||||
const zipBuf = makeSingleEntryZip('full.md', rawDeflated, 10);
|
||||
expect(zipBuf.length).toBeLessThan(MAX_ZIP_BYTES);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await runWithZipResponse(new Response(new Uint8Array(zipBuf), { status: 200 }));
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
const message = String((result as PromiseRejectedResult).reason);
|
||||
expect(message).toContain('text limit');
|
||||
expect(message).not.toContain('mismatch');
|
||||
// Prompt rejection is what shows the cap was enforced during decompression
|
||||
// rather than after the whole 256 MiB entry had been materialized.
|
||||
expect(elapsedMs).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
it('rejects a small lying entry (declared 10 bytes, actual 1 KiB)', async () => {
|
||||
const rawDeflated = zlib.deflateRawSync(Buffer.alloc(1024, 0x62), { level: 6 });
|
||||
const zipBuf = makeSingleEntryZip('full.md', rawDeflated, 10);
|
||||
|
||||
const result = await runWithZipResponse(new Response(new Uint8Array(zipBuf), { status: 200 }));
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
// Under the per-entry cap, so JSZip's own end-of-entry probe (or a limit
|
||||
// check) is the error.
|
||||
expect(String((result as PromiseRejectedResult).reason)).toMatch(/mismatch|limit/i);
|
||||
});
|
||||
|
||||
it('rejects a ZIP with more entries than the entry-count limit', async () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 0; i < MAX_ZIP_ENTRY_COUNT + 1; i++) files[`images/${i}.txt`] = '';
|
||||
const zipBuf = await makeZip(files);
|
||||
|
||||
const result = await runWithZipResponse(new Response(new Uint8Array(zipBuf), { status: 200 }));
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(String((result as PromiseRejectedResult).reason)).toContain('entry limit');
|
||||
});
|
||||
|
||||
it('rejects a ZIP whose declared uncompressed total exceeds the archive budget', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('full.md', '# lesson');
|
||||
const zipBuf = patchFirstEntryDeclaredSize(
|
||||
await zip.generateAsync({ type: 'nodebuffer', compression: 'STORE' }),
|
||||
MAX_ZIP_UNCOMPRESSED_BYTES + 1,
|
||||
);
|
||||
|
||||
const result = await runWithZipResponse(new Response(new Uint8Array(zipBuf), { status: 200 }));
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(String((result as PromiseRejectedResult).reason)).toContain(
|
||||
'declared uncompressed size',
|
||||
);
|
||||
});
|
||||
|
||||
it('still parses a normal ZIP with text, a content list and an image', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('full.md', '# Normal lesson');
|
||||
zip.file('content_list.json', JSON.stringify([{ type: 'image', img_path: 'images/a.png' }]));
|
||||
zip.file('images/a.png', Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
const zipBuf = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
|
||||
const result = await runWithZipResponse(new Response(new Uint8Array(zipBuf), { status: 200 }));
|
||||
|
||||
expect(result.status).toBe('fulfilled');
|
||||
expect((result as PromiseFulfilledResult<{ text: string }>).value.text).toContain(
|
||||
'Normal lesson',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an oversized JSON control-plane response', async () => {
|
||||
const hugeBody = 'x'.repeat(MAX_JSON_BYTES + 1);
|
||||
transport.providerFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 0, msg: hugeBody, data: {} }), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
parseWithMinerUCloud(
|
||||
{
|
||||
providerId: 'mineru-cloud',
|
||||
apiKey: 'cloud-key',
|
||||
baseUrl: 'https://mineru.example/api/v4',
|
||||
},
|
||||
Buffer.from('bytes'),
|
||||
'lesson.pdf',
|
||||
),
|
||||
).rejects.toThrow(/exceeds/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
destroyAudioProviderDispatchersForTests,
|
||||
} from '@/lib/server/audio-provider-fetch';
|
||||
import { validateUrlForSSRFWithPolicy } from '@/lib/server/ssrf-guard';
|
||||
import { REDIRECT_REQUIRES_HTTPS_MESSAGE } from '@/lib/server/fetch-with-redirect-validation';
|
||||
|
||||
const dnsMocks = vi.hoisted(() => ({
|
||||
// Used by the URL-layer guard (`node:dns` promises API).
|
||||
@@ -199,6 +200,44 @@ describe('audioProviderFetch — redirect + rebinding hardening', () => {
|
||||
expect(internal.requests()).toBe(1);
|
||||
});
|
||||
|
||||
it('still follows HTTP redirect hops when requireHttps is off (audio default)', async () => {
|
||||
const internal = await startLoopback((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
const origin = await startLoopback((_req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${internal.port}/final` });
|
||||
res.end();
|
||||
});
|
||||
|
||||
// No `requireHttps`, so the http hop is still followed under the opt-in —
|
||||
// proving the new field is opt-in and does not change audio behavior.
|
||||
const response = await audioProviderFetch(`http://127.0.0.1:${origin.port}/start`, undefined, {
|
||||
allowLocalNetworks: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(internal.requests()).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses an HTTP redirect hop when requireHttps is set', async () => {
|
||||
const internal = await startLoopback();
|
||||
const origin = await startLoopback((_req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${internal.port}/final` });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
audioProviderFetch(`http://127.0.0.1:${origin.port}/start`, undefined, {
|
||||
allowLocalNetworks: true,
|
||||
requireHttps: true,
|
||||
}),
|
||||
).rejects.toThrow(REDIRECT_REQUIRES_HTTPS_MESSAGE);
|
||||
|
||||
expect(origin.requests()).toBe(1);
|
||||
expect(internal.requests()).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a hostname that rebinds to loopback between guard and connect', async () => {
|
||||
const internal = await startLoopback();
|
||||
const url = `http://rebind.test:${internal.port}/secret`;
|
||||
|
||||
@@ -138,6 +138,55 @@ describe('fetchWithRedirectValidation — every redirect hop is re-validated', (
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe('http://127.0.0.1:8080/internal');
|
||||
});
|
||||
|
||||
it('refuses an HTTPS to HTTP redirect hop when requireHttps is set', async () => {
|
||||
const { fetchWithRedirectValidation, REDIRECT_REQUIRES_HTTPS_MESSAGE } = await loadWrapper();
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'http://127.0.0.1:8080/internal' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const error = await fetchWithRedirectValidation(
|
||||
'https://api.public.example/v1/chat/completions',
|
||||
undefined,
|
||||
{ requireHttps: true },
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
const { UnsafeNetworkTargetError } = await import('@/lib/server/ssrf-guard');
|
||||
expect(error).toBeInstanceOf(UnsafeNetworkTargetError);
|
||||
expect((error as Error).message).toContain(REDIRECT_REQUIRES_HTTPS_MESSAGE);
|
||||
// Only the origin request is issued; the HTTP target is never fetched.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('follows an HTTPS to HTTPS redirect hop when requireHttps is set', async () => {
|
||||
const { fetchWithRedirectValidation } = await loadWrapper();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await fetchWithRedirectValidation(
|
||||
'https://api.public.example/v1/chat/completions',
|
||||
undefined,
|
||||
{ requireHttps: true },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe(
|
||||
'https://cdn.public.example/v1/chat/completions',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops credential headers before a cross-origin hop when init.headers is a Headers instance, keeping the other headers', async () => {
|
||||
const { fetchWithRedirectValidation } = await loadWrapper();
|
||||
const fetchMock = vi
|
||||
|
||||
@@ -176,6 +176,26 @@ describe('validateUrlForSSRF', () => {
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('classifies deprecated IPv4-compatible (::/96) literals by their embedded IPv4', async () => {
|
||||
const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
|
||||
|
||||
// The WHATWG parser canonicalizes `[::127.0.0.1]` / `[::a.b.c.d]` to the
|
||||
// compressed `[::xxxx:xxxx]` form, bypassing the IPv4-mapped decoder.
|
||||
await expect(validateUrlForSSRF('http://[::127.0.0.1]/')).resolves.toBe(
|
||||
PRIVATE_NETWORK_BLOCK_MESSAGE,
|
||||
);
|
||||
await expect(validateUrlForSSRF('http://[::7f00:1]/')).resolves.toBe(
|
||||
PRIVATE_NETWORK_BLOCK_MESSAGE,
|
||||
);
|
||||
await expect(validateUrlForSSRF('http://[::a9fe:a9fe]/')).resolves.toBe(
|
||||
CLOUD_METADATA_BLOCK_MESSAGE,
|
||||
);
|
||||
// A public embedded IPv4 stays allowed, mirroring the 6to4/NAT64 fixtures.
|
||||
await expect(validateUrlForSSRF('http://[::8.8.8.8]/')).resolves.toBeNull();
|
||||
await expect(validateUrlForSSRF('http://[::808:808]/')).resolves.toBeNull();
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects private IPv4 embedded in expanded and compressed ISATAP addresses', async () => {
|
||||
const { isPrivateIP } = await import('@/lib/server/ssrf-guard');
|
||||
|
||||
@@ -598,6 +618,17 @@ describe('assertSafeIp', () => {
|
||||
expect(() => assertSafeIp('::ffff:8.8.8.8')).not.toThrow();
|
||||
});
|
||||
|
||||
it('classifies deprecated IPv4-compatible (::/96) addresses by their embedded IPv4', async () => {
|
||||
const { assertSafeIp, isPrivateIP } = await import('@/lib/server/ssrf-guard');
|
||||
|
||||
expect(isPrivateIP('::7f00:1')).toBe(true);
|
||||
expect(isPrivateIP('::a9fe:a9fe')).toBe(true); // link-local metadata
|
||||
expect(isPrivateIP('::808:808')).toBe(false); // 8.8.8.8
|
||||
expect(() => assertSafeIp('::7f00:1')).toThrow(STRICT_BLOCK_MESSAGE);
|
||||
expect(() => assertSafeIp('::a9fe:a9fe')).toThrow(STRICT_BLOCK_MESSAGE);
|
||||
expect(() => assertSafeIp('::808:808')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects ISATAP and NAT64 addresses that embed a metadata or private IPv4', async () => {
|
||||
const { assertSafeIp, isPrivateIP, UnsafeNetworkTargetError } =
|
||||
await import('@/lib/server/ssrf-guard');
|
||||
@@ -752,4 +783,15 @@ describe('connectionAddressBlockReason', () => {
|
||||
expect(reason).not.toContain('not-an-ip');
|
||||
expect(connectionAddressBlockReason('not-an-ip', true)).toBe(PRIVATE_NETWORK_BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('refuses deprecated IPv4-compatible (::/96) addresses at connect time', async () => {
|
||||
const { connectionAddressBlockReason } = await import('@/lib/server/ssrf-guard');
|
||||
|
||||
expect(connectionAddressBlockReason('::7f00:1', false)).toBe(PRIVATE_NETWORK_BLOCK_MESSAGE);
|
||||
expect(connectionAddressBlockReason('::7f00:1', true)).toBeNull();
|
||||
// Metadata stays refused with the opt-in, exactly like its IPv4 form.
|
||||
expect(connectionAddressBlockReason('::a9fe:a9fe', false)).toBe(CLOUD_METADATA_BLOCK_MESSAGE);
|
||||
expect(connectionAddressBlockReason('::a9fe:a9fe', true)).toBe(CLOUD_METADATA_BLOCK_MESSAGE);
|
||||
expect(connectionAddressBlockReason('::808:808', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user