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:
wyuc
2026-09-27 00:07:35 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 21d83ec51b
commit 44762d9db2
12 changed files with 1480 additions and 145 deletions
+287 -38
View File
@@ -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',
+8
View File
@@ -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);
+21
View File
@@ -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';
+19 -3
View File
@@ -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
View File
@@ -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/);
});
});
+39
View File
@@ -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
+42
View File
@@ -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();
});
});