mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-08 07:19:17 +08:00
* 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>
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/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.
|
||
});
|