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
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
// 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.
|
||
});
|