项目文件夹

文件
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

293 行
11 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Unit coverage for the external preview-media cache + SSRF guard
// (apps/daemon/src/plugin-asset-cache.ts).
//
// The load-bearing risk is SSRF: `/api/asset-cache?url=` fetches a
// caller-supplied URL, so these tests pin that private/loopback/link-local
// hosts are refused, that only http(s) media URLs are accepted, and that the
// disk cache fetches once then replays (including concurrent de-duplication).
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
AssetCacheError,
assertSafePublicUrl,
assetCacheKey,
assetCacheRewriteUrl,
createPluginAssetCache,
createValidatingLookup,
isCacheableExternalUrl,
isPrivateAddress,
} from '../src/plugins/plugin-asset-cache.js';
describe('isCacheableExternalUrl', () => {
it('accepts absolute http(s) media urls', () => {
expect(isCacheableExternalUrl('https://res.cloudinary.com/x/portal_bg.png')).toBe(true);
expect(isCacheableExternalUrl('http://images.higgs.ai/a/b.jpg?v=2')).toBe(true);
expect(isCacheableExternalUrl('https://d8j0.cloudfront.net/clip.mp4')).toBe(true);
expect(isCacheableExternalUrl('https://cdn.example.com/a.webp#frag')).toBe(true);
});
it('rejects non-media, relative, data, and non-http urls', () => {
expect(isCacheableExternalUrl('https://fonts.googleapis.com/css2?family=Inter')).toBe(false);
expect(isCacheableExternalUrl('https://cdn.example.com/app.js')).toBe(false);
expect(isCacheableExternalUrl('./hero.png')).toBe(false);
expect(isCacheableExternalUrl('/api/plugins/x/asset/hero.png')).toBe(false);
expect(isCacheableExternalUrl('data:image/png;base64,AAAA')).toBe(false);
expect(isCacheableExternalUrl(42)).toBe(false);
});
});
describe('assetCacheRewriteUrl', () => {
it('encodes the full url into the same-origin proxy path', () => {
expect(assetCacheRewriteUrl('https://res.cloudinary.com/x/a b.png')).toBe(
'/api/asset-cache?url=https%3A%2F%2Fres.cloudinary.com%2Fx%2Fa%20b.png',
);
});
});
describe('isPrivateAddress', () => {
it('flags loopback / private / link-local / CGNAT / multicast', () => {
for (const addr of [
'127.0.0.1',
'10.0.0.5',
'172.16.0.1',
'172.31.255.255',
'192.168.1.1',
'169.254.1.1',
'100.64.0.1',
'0.0.0.0',
'224.0.0.1',
'::1',
'::',
'fe80::1',
'fc00::1',
'fd12::3',
'ff02::1',
'fe80::1', // link-local low end
'fe90::1', // link-local — was missed by an exact fe80 match
'febf::1', // link-local high end (fe80::/10)
'::ffff:127.0.0.1',
'::ffff:7f00:1', // hex IPv4-mapped 127.0.0.1 (Node normalizes brackets to this)
'::ffff:0a00:1', // hex IPv4-mapped 10.0.0.1
'::ffff:a9fe:a9fe', // hex IPv4-mapped 169.254.169.254 (metadata)
'not-an-ip',
]) {
expect(isPrivateAddress(addr)).toBe(true);
}
});
it('allows ordinary public addresses', () => {
for (const addr of [
'8.8.8.8',
'1.1.1.1',
'172.15.0.1',
'172.32.0.1',
'2606:4700:4700::1111',
'::ffff:5db8:d822', // hex IPv4-mapped 93.184.216.34 (public)
]) {
expect(isPrivateAddress(addr)).toBe(false);
}
});
});
describe('assertSafePublicUrl (up-front rejection)', () => {
it('rejects unsupported schemes and embedded credentials', () => {
expect(() => assertSafePublicUrl('ftp://host/x.png')).toThrow(
expect.objectContaining({ status: 400 }),
);
expect(() => assertSafePublicUrl('https://user:pass@host/x.png')).toThrow(
expect.objectContaining({ status: 400 }),
);
expect(() => assertSafePublicUrl('not a url')).toThrow(AssetCacheError);
});
it('rejects localhost and literal private IPs before any socket opens', () => {
expect(() => assertSafePublicUrl('http://localhost/x.png')).toThrow(
expect.objectContaining({ status: 400 }),
);
expect(() => assertSafePublicUrl('http://127.0.0.1/x.png')).toThrow(
expect.objectContaining({ status: 400 }),
);
expect(() => assertSafePublicUrl('http://169.254.169.254/x.png')).toThrow(
expect.objectContaining({ status: 400 }),
);
// IPv4-mapped IPv6 literal — the URL parser normalizes the bracketed host
// to `::ffff:7f00:1`, which must still be rejected as loopback.
expect(() => assertSafePublicUrl('http://[::ffff:127.0.0.1]/x.png')).toThrow(
expect.objectContaining({ status: 400 }),
);
});
it('accepts a public host (DNS is validated later, at connection time)', () => {
expect(assertSafePublicUrl('https://res.cloudinary.com/x/a.png').hostname).toBe('res.cloudinary.com');
});
});
describe('createValidatingLookup (DNS-rebinding / TOCTOU guard)', () => {
// The lookup the Agent actually connects through: whatever address it
// resolves is the address that must pass, so a rebinding resolver cannot
// hand a public IP to a pre-check and a private IP to the real fetch.
function run(lookupImpl: (h: string, o: unknown, cb: (e: Error | null, a?: unknown, f?: number) => void) => void) {
return new Promise<{ err: Error | null; address?: unknown }>((resolve) => {
createValidatingLookup(lookupImpl as never)('host.example', { all: false }, (err, address) =>
resolve({ err, address }),
);
});
}
it('passes a public resolved address through', async () => {
const { err, address } = await run((_h, _o, cb) => cb(null, '93.184.216.34', 4));
expect(err).toBeNull();
expect(address).toBe('93.184.216.34');
});
it('rejects when the resolved address is private (the rebinding case)', async () => {
const { err } = await run((_h, _o, cb) => cb(null, '169.254.169.254', 4));
expect(err).toBeInstanceOf(AssetCacheError);
expect((err as AssetCacheError).status).toBe(400);
});
it('rejects when any address in an all:true result is private', async () => {
const { err } = await run((_h, _o, cb) =>
cb(null, [
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.9', family: 4 },
]),
);
expect(err).toBeInstanceOf(AssetCacheError);
});
});
describe('createPluginAssetCache', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), 'od-asset-cache-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
function pngResponse(bytes = 8): Response {
return new Response(Buffer.alloc(bytes, 1), {
status: 200,
headers: { 'content-type': 'image/png', 'content-length': String(bytes) },
});
}
it('fetches once, stores on disk, and replays from cache', async () => {
let calls = 0;
const cache = createPluginAssetCache({
cacheDir: dir,
fetchImpl: (async () => {
calls += 1;
return pngResponse();
}) as typeof fetch,
});
const url = 'https://res.cloudinary.com/x/portal_bg.png';
const first = await cache.get(url);
expect(first.contentType).toBe('image/png');
expect(first.buf.byteLength).toBe(8);
// On-disk blob exists under the sha256 key.
const onDisk = await readFile(path.join(dir, assetCacheKey(url)));
expect(onDisk.byteLength).toBe(8);
// Second call is served from disk — no extra fetch.
const second = await cache.get(url);
expect(second.buf.byteLength).toBe(8);
expect(calls).toBe(1);
});
it('de-duplicates concurrent requests for the same url', async () => {
let calls = 0;
const cache = createPluginAssetCache({
cacheDir: dir,
fetchImpl: (async () => {
calls += 1;
await new Promise((r) => setTimeout(r, 10));
return pngResponse();
}) as typeof fetch,
});
const url = 'https://images.higgs.ai/a/b.jpg';
const [a, b] = await Promise.all([cache.get(url), cache.get(url)]);
expect(a.buf.byteLength).toBe(8);
expect(b.buf.byteLength).toBe(8);
expect(calls).toBe(1);
});
it('derives content-type from the extension when the header is generic', async () => {
const cache = createPluginAssetCache({
cacheDir: dir,
fetchImpl: (async () =>
new Response(Buffer.alloc(4, 2), {
status: 200,
headers: { 'content-type': 'application/octet-stream' },
})) as typeof fetch,
});
const out = await cache.get('https://cdn.example.com/clip.mp4');
expect(out.contentType).toBe('video/mp4');
});
it('rejects oversized assets (declared Content-Length)', async () => {
const cache = createPluginAssetCache({
cacheDir: dir,
maxBytes: 16,
fetchImpl: (async () => pngResponse(1024)) as typeof fetch,
});
await expect(cache.get('https://res.cloudinary.com/x/big.png')).rejects.toMatchObject({ status: 413 });
});
it('stops reading a no-Content-Length body once it exceeds the cap (no full buffering)', async () => {
let pulls = 0;
// 100 chunks × 8 bytes = 800 bytes, streamed, with NO content-length.
const stream = new ReadableStream<Uint8Array>({
pull(controllerStream) {
pulls += 1;
if (pulls > 100) return controllerStream.close();
controllerStream.enqueue(new Uint8Array(8).fill(7));
},
});
const cache = createPluginAssetCache({
cacheDir: dir,
maxBytes: 16, // two 8-byte chunks fit; the third must trip the cap
fetchImpl: (async () =>
new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } })) as typeof fetch,
});
await expect(cache.get('https://res.cloudinary.com/x/stream.png')).rejects.toMatchObject({ status: 413 });
// It must abort after a few chunks, NOT drain all 100 (no full buffering).
expect(pulls).toBeLessThan(10);
});
it('refuses non-cacheable urls without fetching', async () => {
let calls = 0;
const cache = createPluginAssetCache({
cacheDir: dir,
fetchImpl: (async () => {
calls += 1;
return pngResponse();
}) as typeof fetch,
});
await expect(cache.get('https://cdn.example.com/app.js')).rejects.toMatchObject({ status: 400 });
expect(calls).toBe(0);
});
it('refuses a literal private-IP url before fetching (SSRF up-front guard)', async () => {
let calls = 0;
const cache = createPluginAssetCache({
cacheDir: dir,
fetchImpl: (async () => {
calls += 1;
return pngResponse();
}) as typeof fetch,
});
// 169.254.169.254 is the cloud metadata endpoint — must never be fetched.
await expect(cache.get('https://169.254.169.254/x.png')).rejects.toMatchObject({ status: 400 });
expect(calls).toBe(0);
});
// DNS-rebinding (host that *resolves* to a private address) is covered by the
// createValidatingLookup suite above — that guard runs at connection time,
// inside the undici Agent, not through the injectable fetchImpl.
});