Files
nexu-io-open-design/apps/daemon/tests/plugin-asset-cache.test.ts
elihahah666 a93869ee09 feat(plugins): 25 motionsites example plugins + daemon asset cache (#3873)
* feat(plugins): add 25 motionsites example plugins + daemon asset cache

Add 25 first-party example scenario plugins under
plugins/_official/examples/ (packaged from motionsites.ai templates via the
motionsites-to-open-design-plugin skill): each ships open-design.json,
SKILL.md, and a self-contained rendered example.html fidelity seed. All 25
pass PluginManifestSchema.

Their cross-border CDN images/videos (cloudinary, higgs.ai, motionsites,
cloudfront) are mirrored to Cloudflare R2 (plugin-assets.open-design.ai) and
high-fidelity compressed (732MB -> 302MB), so example.html / query / SKILL.md
reference the fast public origin instead of slow cross-border hosts.

Daemon asset cache (plugin-asset-cache.ts + /api/asset-cache): a same-origin
disk-cached proxy for any external preview media that remains. The preview
URL rewriter now routes external media (src/poster attrs, CSS url(), and JS
string constants) through it, so cross-border assets satisfy the sandbox CSP
(img-src 'self') and load from local cache. SSRF-guarded: http(s) only, no
credentials, and every resolved address must be public.

* fix(asset-cache): tie SSRF validation to the actual outbound connection

The previous guard resolved DNS in assertSafePublicUrl and then let fetch()
resolve again independently, so a DNS-rebinding host could pass the validation
lookup with a public address and steer the real connection to a private one
(e.g. 169.254.169.254). Move the authoritative check to a connection-time
undici Agent lookup (createValidatingLookup): the address that is validated is
the exact address the socket connects to, closing the TOCTOU gap.
assertSafePublicUrl is now a cheap up-front reject (scheme / credentials /
localhost / literal private IP) only. Adds createValidatingLookup unit tests
(public pass, rebinding-to-private reject, all:true any-private reject).

* fix(asset-cache): reject IPv4-mapped IPv6 literals in the private-IP guard

Node's URL parser normalizes a bracketed mapped literal like
`http://[::ffff:127.0.0.1]/` to the hex form `::ffff:7f00:1`, which the old
dotted-decimal regex missed — so a literal mapped host slipped past
assertSafePublicUrl (and literal IPs skip the DNS lookup hook), letting
`/api/asset-cache` target loopback/private IPv4 via its mapped representation.

Canonicalize IPv6 now: expandIpv6() folds `::` compression and any embedded
dotted IPv4 into eight 16-bit groups, and isPrivateAddress() detects the full
`::ffff:0:0/96` mapped range (hex or dotted), feeding the embedded IPv4 back
through the v4 private-range checks. Group-based classification also replaces
the prefix string-matching for ::, ::1, fe80::/10, fc00::/7, ff00::/8.
Regression coverage added for ::ffff:7f00:1 / ::ffff:127.0.0.1 and
http://[::ffff:127.0.0.1]/x.png.

* fix(asset-cache): full link-local range + stream-cap the upstream body

Two SSRF/DoS hardening fixes on the cache path:

- Link-local was matched as the single fe80:: prefix, leaking the rest of
  fe80::/10 (fe90::, febf::, …) through as "public". Classify with a mask
  ((groups[0] & 0xffc0) === 0xfe80); ULA/multicast use masks too now.

- maxBytes was not a hard ceiling for responses without a trustworthy
  Content-Length: arrayBuffer() buffered the whole body before the size check,
  a memory-exhaustion path for a caller-supplied proxy. Stream the body and
  abort the moment the accumulated size exceeds maxBytes, before concatenating.

Tests: fe80::1 / fe90::1 / febf::1 rejected; a no-Content-Length 800-byte
stream against a 16-byte cap rejects with 413 after <10 pulls (proves it stops
reading instead of buffering the full body).

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-06-08 07:31:41 +00:00

293 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/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.
});