feat(onebot): download stream actions (#163 phase 3)

NapCat-compatible server-push download: one request answered by a file_info
header, N base64 file_chunk frames, then a terminal file_complete. Bytes come
from a local file under the stream temp root, an http(s) URL, or a cached
image/voice id resolved to its QQ CDN URL. test_download_stream pushes 10
canned frames so a client can validate the transport without touching QQ.

Actions: download_file_stream / download_file_image_stream /
download_file_record_stream / test_download_stream.

Hardened beyond the NapCat original (which streams any local path and follows
redirects to anywhere), per three adversarial review passes:
- arbitrary-file-read: local reads are fenced inside the stream temp root,
  realpath-resolved so a symlink can't point out.
- SSRF: every URL (incl. each redirect hop, followed manually) is guarded —
  http(s) only, and neither the literal host nor any resolved address may be
  private/loopback/link-local; IPv6 is byte-parsed so normalized v4-mapped
  literals (::ffff:a9fe:a9fe) can't slip past. (DNS rebinding remains a
  documented residual — needs connect-time peer-IP validation.)
- robustness: TTFB-only connect timeout (doesn't cut a large body mid-stream)
  plus a per-chunk idle watchdog that aborts a stalled source so a hung
  upstream can't pin a socket; total size capped; non-2xx bodies cancelled;
  client disconnect aborts via the sink.

501 onebot tests green.
This commit is contained in:
DrSmoothl
2026-06-30 14:40:39 +08:00
parent 4f0a0f89ae
commit 369dea582c
4 changed files with 498 additions and 1 deletions
@@ -0,0 +1,379 @@
// Stream API download actions (#163) — NapCat-compatible server-push streaming.
//
// download_file_stream / _image_stream / _record_stream answer one request with
// many frames: a `file_info` header, then N `file_chunk` frames (base64), then a
// terminal `file_complete`. The bytes come from one of three sources, resolved
// from the `file` arg:
// • a local file UNDER the stream temp root (e.g. something a client just
// pushed via upload_file_stream) — streamed straight off disk;
// • an http(s) URL — fetched and re-streamed;
// • a cached image/voice id from a received message — resolved to its QQ CDN
// URL, then fetched.
// test_download_stream pushes 10 canned frames so a client can verify the
// streaming transport without touching QQ.
//
// Hardening beyond the NapCat original (which streams ANY local path and
// fetches ANY URL): local reads are fenced inside the stream temp root (no
// arbitrary-file read via a leaked token), and URL fetches are SSRF-guarded
// (no private/loopback/link-local targets) + size-capped.
import fs from 'fs';
import path from 'path';
import net from 'net';
import dns from 'dns/promises';
import { resolveLocalFilePath } from '@snowluma/protocol/highway/utils';
import { defineStreamAction, f } from '../action-kit';
import type { ApiActionContext, ApiHandler } from '../api-handler';
import { okResponse, type ApiResponse, type JsonObject, type JsonValue } from '../types';
import { type StreamSink, StreamStatus } from '../streaming';
import { STREAM_ROOT } from './stream-file';
const DEFAULT_CHUNK_BYTES = 64 * 1024;
const MAX_CHUNK_REQUEST_BYTES = 16 * 1024 * 1024; // cap the client-requested chunk size
const MAX_DOWNLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB total
const FETCH_CONNECT_TIMEOUT_MS = 30_000; // TTFB only — cleared once headers arrive (not the whole transfer)
const IDLE_TIMEOUT_MS = 60_000; // abort if no chunk arrives for this long (anti-stall)
const MAX_REDIRECTS = 5;
const DOWNLOAD_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
class DownloadError extends Error {}
// ─────────────────────────── source resolution ───────────────────────────
function isWithinStreamRoot(p: string): boolean {
// Resolve the root through any symlinks too (e.g. macOS /tmp → /private/tmp),
// so a realpath'd target compares against a realpath'd root — otherwise a
// legitimate file reads as "outside" purely from the tmpdir symlink.
let root = path.resolve(STREAM_ROOT);
try { root = fs.realpathSync(STREAM_ROOT); } catch { /* root may not exist yet */ }
const rel = path.relative(root, path.resolve(p));
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function isPrivateV4(o: number[]): boolean {
return (
o[0] === 0 || o[0] === 127 || o[0] === 10 ||
(o[0] === 192 && o[1] === 168) ||
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
(o[0] === 169 && o[1] === 254) || // link-local
(o[0] === 100 && o[1] >= 64 && o[1] <= 127) // CGNAT
);
}
/** Parse an IPv6 string (any form, incl. `::` compression and embedded IPv4)
* to its 16 bytes. `new URL().hostname` normalises v4-mapped addresses to hex
* (`::ffff:7f00:1`), so string matching is unreliable — we work on bytes. */
function ipv6ToBytes(input: string): Uint8Array | null {
let s = input.replace(/^\[|\]$/g, '');
if (net.isIP(s) !== 6) return null;
const v4m = /:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s); // embedded IPv4 tail
if (v4m) {
const o = v4m[1].split('.').map(Number);
if (o.some((n) => n > 255)) return null;
s = s.slice(0, v4m.index) + ':' + `${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const halves = s.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
let groups: string[];
if (halves.length === 2) {
const missing = 8 - head.length - tail.length;
if (missing < 0) return null;
groups = [...head, ...new Array(missing).fill('0'), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) return null;
const bytes = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const g = parseInt(groups[i] || '0', 16);
if (Number.isNaN(g) || g < 0 || g > 0xffff) return null;
bytes[i * 2] = g >> 8;
bytes[i * 2 + 1] = g & 0xff;
}
return bytes;
}
function isPrivateIp(ip: string): boolean {
const host = ip.replace(/^\[|\]$/g, '');
const fam = net.isIP(host);
if (fam === 4) return isPrivateV4(host.split('.').map(Number));
if (fam === 6) {
const b = ipv6ToBytes(host);
if (!b) return true; // unparseable IPv6 → treat as unsafe
if (b.every((x) => x === 0)) return true; // ::
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1) return true; // ::1
if ((b[0] & 0xfe) === 0xfc) return true; // fc00::/7 unique-local
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return true; // fe80::/10 link-local
// v4-mapped ::ffff:a.b.c.d / v4-compatible ::a.b.c.d
if (b.slice(0, 10).every((x) => x === 0) && ((b[10] === 0xff && b[11] === 0xff) || (b[10] === 0 && b[11] === 0))) {
return isPrivateV4([b[12], b[13], b[14], b[15]]);
}
// NAT64 64:ff9b::/96
if (b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every((x) => x === 0)) {
return isPrivateV4([b[12], b[13], b[14], b[15]]);
}
return false;
}
return false;
}
/** Parse + SSRF-guard a URL: http(s) only, and neither the literal host nor any
* DNS-resolved address may be private/loopback/link-local. Not bulletproof
* against DNS rebinding, but blocks the obvious metadata-endpoint / internal
* service reach. */
async function assertSafeUrl(raw: string): Promise<URL> {
let u: URL;
try { u = new URL(raw); } catch { throw new DownloadError('invalid URL'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new DownloadError('only http(s) URLs are allowed');
const host = u.hostname.replace(/^\[|\]$/g, '');
if (net.isIP(host)) {
if (isPrivateIp(host)) throw new DownloadError('refusing to fetch a private/loopback address');
return u;
}
let addrs: Array<{ address: string }>;
try { addrs = await dns.lookup(host, { all: true }); } catch { throw new DownloadError(`cannot resolve host: ${host}`); }
for (const a of addrs) {
if (isPrivateIp(a.address)) throw new DownloadError('refusing to fetch a host that resolves to a private address');
}
return u;
}
interface DownloadSource {
name: string;
size: number; // 0 when unknown
iterable: AsyncIterable<Uint8Array>;
/** Force the underlying source closed (idle-timeout watchdog / abort). */
abort?: () => void;
}
const asStr = (v: JsonValue | undefined): string => (typeof v === 'string' ? v : '');
const asNum = (v: JsonValue | undefined): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : 0;
};
/** Follow redirects MANUALLY, SSRF-guarding every hop. Default fetch follows
* redirects automatically and would NOT re-check the target, so a public URL
* could 302 to a private/metadata address — re-validating each Location closes
* that. Each hop gets a TTFB-only timeout (cleared once headers arrive, so a
* legitimate slow/large body isn't cut mid-transfer); the final hop's
* AbortController is returned so the caller's idle watchdog can kill a stalled
* body. (DNS rebinding between our check and undici's own connect remains a
* known residual — it needs connect-time peer-IP validation to fully close.) */
async function fetchGuarded(rawUrl: string): Promise<{ resp: Response; abort: () => void }> {
let current = rawUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const u = await assertSafeUrl(current);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), FETCH_CONNECT_TIMEOUT_MS);
let resp: Response;
try {
resp = await fetch(u, { headers: { 'User-Agent': DOWNLOAD_USER_AGENT }, redirect: 'manual', signal: ac.signal });
} finally {
clearTimeout(t);
}
if (resp.status >= 300 && resp.status < 400) {
const loc = resp.headers.get('location');
await resp.body?.cancel().catch(() => { /* ignore */ });
if (!loc) throw new DownloadError(`download failed: ${resp.status} redirect without Location`);
current = new URL(loc, u).toString();
continue;
}
return { resp, abort: () => ac.abort() };
}
throw new DownloadError('download failed: too many redirects');
}
async function openUrl(rawUrl: string, name: string, knownSize: number): Promise<DownloadSource> {
const { resp, abort } = await fetchGuarded(rawUrl);
if (!resp.ok) {
await resp.body?.cancel().catch(() => { /* ignore */ });
throw new DownloadError(`download failed: HTTP ${resp.status}`);
}
const declared = Number(resp.headers.get('content-length') ?? '');
const size = (Number.isFinite(declared) && declared > 0 ? declared : 0) || knownSize;
if (!resp.body) throw new DownloadError('download failed: empty response body');
return { name: name || urlBasename(rawUrl), size, iterable: resp.body as unknown as AsyncIterable<Uint8Array>, abort };
}
function urlBasename(u: string): string {
try {
const name = path.basename(new URL(u).pathname);
return name && name !== '/' ? decodeURIComponent(name) : 'download.bin';
} catch { return 'download.bin'; }
}
async function resolveDownload(
target: string,
ctx: ApiActionContext,
prefer: 'auto' | 'image' | 'record',
chunkSize: number,
): Promise<DownloadSource> {
// 1. local file — but ONLY a real existing file, fenced inside the stream
// temp root. `resolveLocalFilePath` returns the input verbatim for any
// non-URL string (including cached-media ids), so we must NOT treat a
// non-existent "path" as local — that would shadow the cached-media branch
// below (the whole point of the *_image/_record_stream actions).
const local = resolveLocalFilePath(target);
if (local) {
let st: fs.Stats | null = null;
try { st = fs.statSync(local); } catch { st = null; }
if (st?.isFile()) {
// Fence against the *real* path so a symlink inside the root can't point
// out (lexical resolve alone would follow it).
let real = local;
try { real = fs.realpathSync(local); } catch { /* keep lexical */ }
if (!isWithinStreamRoot(real)) throw new DownloadError('local downloads are restricted to the stream temp directory');
const rs = fs.createReadStream(real, { highWaterMark: chunkSize });
return { name: path.basename(real), size: st.size, iterable: rs, abort: () => rs.destroy() };
}
}
// 2. explicit http(s) URL.
if (/^https?:\/\//i.test(target)) return openUrl(target, '', 0);
// 3. cached image / voice id → QQ CDN URL.
let info: JsonObject | null = null;
if (prefer !== 'record') info = await ctx.getImageInfo(target);
if (!info && prefer !== 'image') info = await ctx.getRecordInfo(target);
if (info) {
const url = asStr(info.url) || asStr(info.file);
if (/^https?:\/\//i.test(url)) return openUrl(url, asStr(info.file_name), asNum(info.file_size));
}
throw new DownloadError('file not found (expected a stream-dir path, an http(s) URL, or a cached image/voice id)');
}
// ─────────────────────────── push engine ───────────────────────────
async function streamChunks(
sink: StreamSink,
iterable: AsyncIterable<Uint8Array>,
totalSize: number,
dataType: string,
abort?: () => void,
): Promise<{ totalChunks: number; totalBytes: number }> {
let index = 0;
let bytesRead = 0;
// Anti-stall: if no chunk arrives for IDLE_TIMEOUT_MS, force the source closed
// so a server that sends headers then hangs can't pin a socket forever (the
// sink's own liveness check only fires when we actually send a frame).
let idle: NodeJS.Timeout | null = null;
const armIdle = (): void => {
if (idle) clearTimeout(idle);
idle = setTimeout(() => { abort?.(); }, IDLE_TIMEOUT_MS);
if (idle.unref) idle.unref();
};
armIdle();
try {
for await (const part of iterable) {
armIdle();
const buf = Buffer.isBuffer(part) ? part : Buffer.from(part);
bytesRead += buf.length;
if (bytesRead > MAX_DOWNLOAD_BYTES) throw new DownloadError(`download exceeds size limit (${MAX_DOWNLOAD_BYTES} bytes)`);
const b64 = buf.toString('base64');
await sink.send({
type: StreamStatus.Stream,
data_type: dataType,
index,
data: b64,
size: buf.length,
progress: totalSize > 0 ? Math.min(100, Math.round((bytesRead / totalSize) * 100)) : 0,
base64_size: b64.length,
});
index++;
}
} finally {
if (idle) clearTimeout(idle);
}
return { totalChunks: index, totalBytes: bytesRead };
}
async function runDownload(
target: string,
chunkSizeReq: number | undefined,
ctx: ApiActionContext,
sink: StreamSink,
prefer: 'auto' | 'image' | 'record',
): Promise<ApiResponse> {
if (!target) throw new DownloadError('file is required');
let chunkSize = chunkSizeReq && chunkSizeReq > 0 ? chunkSizeReq : DEFAULT_CHUNK_BYTES;
if (chunkSize > MAX_CHUNK_REQUEST_BYTES) chunkSize = MAX_CHUNK_REQUEST_BYTES;
const src = await resolveDownload(target, ctx, prefer, chunkSize);
await sink.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: src.name,
file_size: src.size,
chunk_size: chunkSize,
});
const { totalChunks, totalBytes } = await streamChunks(sink, src.iterable, src.size, 'file_chunk', src.abort);
return okResponse({
type: StreamStatus.Response,
data_type: 'file_complete',
file_name: src.name,
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed',
});
}
// ─────────────────────────── action definitions ───────────────────────────
const downloadParams = {
file: f.string().optional().describe('文件路径(限 stream 临时目录)/ http(s) URL'),
file_id: f.string().optional().describe('文件 ID(缓存的图片/语音 id)'),
chunk_size: f.int({ min: 1 }).optional().describe('分块大小(字节,默认 64KB)'),
};
const downloadFileStream = defineStreamAction({
name: 'download_file_stream',
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, _raw, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'auto'),
});
const downloadFileImageStream = defineStreamAction({
name: 'download_file_image_stream',
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, _raw, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'image'),
});
const downloadFileRecordStream = defineStreamAction({
name: 'download_file_record_stream',
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, _raw, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'record'),
});
const testDownloadStream = defineStreamAction({
name: 'test_download_stream',
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
returns: '流式帧:data_chunk*10 → data_complete(error=true 时以 error 帧结束)',
params: { error: f.bool().default(false).describe('是否触发测试错误') },
run: async (p, _ctx, _raw, sink) => {
for (let i = 0; i < 10; i++) {
await sink.send({ type: StreamStatus.Stream, data: `Index-> ${i + 1}`, data_type: 'data_chunk' });
}
if (p.error) throw new DownloadError('This is a test error');
return okResponse({ type: StreamStatus.Response, data_type: 'data_complete', data: 'Stream transmission complete' });
},
});
export function register(h: ApiHandler, ctx: ApiActionContext): void {
downloadFileStream.register(h, ctx);
downloadFileImageStream.register(h, ctx);
downloadFileRecordStream.register(h, ctx);
testDownloadStream.register(h, ctx);
}
// Exposed for tests.
export const __test = { resolveDownload, isWithinStreamRoot, isPrivateIp, assertSafeUrl, streamChunks, runDownload };
+1 -1
View File
@@ -29,7 +29,7 @@ import { StreamStatus } from '../streaming';
const log = createLogger('OneBot.Stream');
const STREAM_ROOT = path.join(os.tmpdir(), 'snowluma-stream');
export const STREAM_ROOT = path.join(os.tmpdir(), 'snowluma-stream');
export const STREAM_UPLOAD_DIR = path.join(STREAM_ROOT, 'upload');
export const STREAM_DOWNLOAD_DIR = path.join(STREAM_ROOT, 'download');
+2
View File
@@ -12,6 +12,7 @@ import { register as registerMessage } from './actions/message';
import { register as registerQzone } from './actions/qzone';
import { register as registerRequest } from './actions/request';
import { register as registerStreamFile } from './actions/stream-file';
import { register as registerStreamDownload } from './actions/stream-download';
import type { ForwardPreviewMeta } from './modules/message-actions';
import type { JsonObject, JsonValue, MessageMeta } from './types';
import { RETCODE, failedResponse } from './types';
@@ -122,6 +123,7 @@ export class ApiHandler {
registerGroupAlbum(this, context);
registerQzone(this, context);
registerStreamFile(this, context);
registerStreamDownload(this, context);
}
registerAction(action: string, handler: ActionHandler): void {
@@ -0,0 +1,116 @@
// Phase 3 — download stream actions (#163): server-push multi-frame.
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import { __test } from '../src/actions/stream-download';
import { STREAM_DOWNLOAD_DIR, __test as fileTest } from '../src/actions/stream-file';
const parse = (frames: string[]) => frames.map((f) => JSON.parse(f) as Record<string, any>);
afterEach(() => {
fileTest.cleanDir(STREAM_DOWNLOAD_DIR);
});
describe('test_download_stream', () => {
it('pushes 10 data frames then a data_complete terminal', async () => {
const h = new ApiHandler({} as ApiActionContext);
expect(h.isStreamAction('test_download_stream')).toBe(true);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'test_download_stream', params: {}, echo: 'T' }), (j) => frames.push(j));
const got = parse(frames);
expect(got).toHaveLength(11);
expect(got[0]).toMatchObject({ stream: 'stream-action', echo: 'T', data: { type: 'stream', data_type: 'data_chunk', data: 'Index-> 1' } });
expect(got[9]).toMatchObject({ data: { data: 'Index-> 10' } });
expect(got[10]).toMatchObject({ status: 'ok', data: { type: 'response', data_type: 'data_complete', data: 'Stream transmission complete' } });
});
it('error=true streams the 10 frames then terminates with an error frame', async () => {
const h = new ApiHandler({} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'test_download_stream', params: { error: true } }), (j) => frames.push(j));
const got = parse(frames);
expect(got).toHaveLength(11);
expect(got[9]).toMatchObject({ data: { data_type: 'data_chunk' } });
expect(got[10]).toMatchObject({ status: 'failed', data: { type: 'error' } });
});
});
describe('download_file_stream (local, fenced)', () => {
it('streams file_info → file_chunk* → file_complete and reassembles exactly', async () => {
fs.mkdirSync(STREAM_DOWNLOAD_DIR, { recursive: true });
const fp = path.join(STREAM_DOWNLOAD_DIR, 'dl.bin');
const content = Buffer.from('snowluma stream download payload '.repeat(4000)); // ~128 KB → multiple 64 KB chunks
fs.writeFileSync(fp, content);
const h = new ApiHandler({} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'download_file_stream', params: { file: fp, chunk_size: 65536 }, echo: 'D' }), (j) => frames.push(j));
const got = parse(frames);
expect(got[0]).toMatchObject({ stream: 'stream-action', echo: 'D', data: { data_type: 'file_info', file_name: 'dl.bin', file_size: content.length, chunk_size: 65536 } });
const chunks = got.filter((p) => p.data?.data_type === 'file_chunk');
expect(chunks.length).toBeGreaterThan(1);
// indices are sequential and progress is monotonic
chunks.forEach((c, i) => expect(c.data.index).toBe(i));
const last = got[got.length - 1];
expect(last).toMatchObject({ status: 'ok', data: { type: 'response', data_type: 'file_complete', total_bytes: content.length, total_chunks: chunks.length } });
const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data.data, 'base64')));
expect(reassembled.equals(content)).toBe(true);
});
it('refuses an arbitrary local path outside the stream root [arbitrary-read guard]', async () => {
await expect(__test.resolveDownload('/etc/passwd', {} as ApiActionContext, 'auto', 65536)).rejects.toThrow(/restricted to the stream temp/i);
await expect(__test.resolveDownload('file:///etc/hosts', {} as ApiActionContext, 'auto', 65536)).rejects.toThrow(/restricted to the stream temp/i);
// and end-to-end the dispatch surfaces it as an error frame, not a leak
const h = new ApiHandler({} as ApiActionContext);
const frames: string[] = [];
await h.processStreamRequest(JSON.stringify({ action: 'download_file_stream', params: { file: '/etc/passwd' } }), (j) => frames.push(j));
expect(parse(frames).at(-1)).toMatchObject({ status: 'failed', data: { type: 'error' } });
});
});
describe('SSRF guard', () => {
it('classifies private/loopback/link-local addresses (incl. normalized v4-mapped IPv6) [C2]', () => {
const priv = [
'127.0.0.1', '10.0.0.5', '192.168.1.1', '172.16.0.1', '169.254.1.1', '0.0.0.0', '100.64.0.1',
'::1', 'fc00::1', 'fd12::1', 'fe80::1',
'::ffff:127.0.0.1', '::ffff:169.254.169.254', // dotted v4-mapped
'::ffff:7f00:1', '::ffff:a9fe:a9fe', // hex v4-mapped (what `new URL` produces)
'::127.0.0.1', '64:ff9b::7f00:1', // v4-compatible, NAT64-mapped loopback
];
for (const ip of priv) expect(__test.isPrivateIp(ip), ip).toBe(true);
for (const ip of ['8.8.8.8', '1.1.1.1', '203.0.113.4', '2606:4700:4700::1111', '::ffff:8.8.8.8']) {
expect(__test.isPrivateIp(ip), ip).toBe(false);
}
});
it('rejects non-http(s) schemes and private/loopback URL targets — incl. v4-mapped literals [C1/C2]', async () => {
await expect(__test.assertSafeUrl('ftp://example.com/x')).rejects.toThrow(/http\(s\)/i);
await expect(__test.assertSafeUrl('file:///etc/passwd')).rejects.toThrow(/http\(s\)/i);
await expect(__test.assertSafeUrl('http://127.0.0.1/x')).rejects.toThrow(/private\/loopback/i);
await expect(__test.assertSafeUrl('http://[::1]/x')).rejects.toThrow(/private\/loopback/i);
// v4-mapped IPv6 literal → new URL normalizes hostname to hex; must still block
await expect(__test.assertSafeUrl('http://[::ffff:127.0.0.1]/x')).rejects.toThrow(/private\/loopback/i);
await expect(__test.assertSafeUrl('http://[::ffff:169.254.169.254]/meta')).rejects.toThrow(/private\/loopback/i);
await expect(__test.assertSafeUrl('http://localhost/x')).rejects.toThrow(/private/i);
});
});
describe('cached image/voice id resolution reaches the media branch [M1]', () => {
it('a cache id flows past the local check to getImageInfo (then SSRF-guards its URL)', async () => {
const ctx = {
getImageInfo: async () => ({ url: 'http://10.0.0.7/cached.png', file: '', file_name: 'cached.png', file_size: '9' }),
getRecordInfo: async () => null,
} as unknown as ApiActionContext;
// 'abc123.image' is not a real file → must NOT be rejected as "restricted";
// it reaches getImageInfo, whose private URL is then blocked by the SSRF guard.
await expect(__test.resolveDownload('abc123.image', ctx, 'image', 65536)).rejects.toThrow(/private/i);
});
});