openvibers/openvibe.ai Commits
Fetcher: a redirect from a public allow-listed host may not reach the internal Media origin. Every hop was judged, but judge() let the internal origin through on any hop, so an allow-listed host that redirects could make the service fetch from loopback Media; now only a hop that is itself internal may redirect there (tests script redirect chains through a transport seam)
Changes
@@@ -141,7 +141,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` || The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` || Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |--| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and redirects re-checked; size caps | `test/ssrf.test.js` |++| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` || Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` || Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` || Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |@@@ -9,7 +9,9 @@* - the address actually connected to must be public unicast: the DNS answer is checked inside* the connection's own lookup (no second resolution, so no DNS rebinding), and IPv4-mapped* IPv6 spellings of internal addresses are caught too-- * - redirects are followed by hand (max 3) and every hop is judged by the same rules++ * - redirects are followed by hand (max 3) and every hop is judged by the same rules; the internal++ * Media origin is reachable by redirect only from the internal origin itself, never from a++ * public hop (an allow-listed host that redirects must not reach the loopback Media service)* - a byte cap, a timeout and cancellation on every transfer* data: URLs (images a caller sends inline) are decoded locally with a size cap — never fetched.* A MediaRef resolves to the public Media URL for the legacy kinds; med_ ids are an explicit@@@ -71,7 +73,8 @@ function hostAllowed(hostname, allowHosts) {});}--function createFetcher(config) {++/** `transport` replaces the single-hop HTTP request (tests script redirect chains with it). */++function createFetcher(config, { transport = null } = {}) {const m = config.media;const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;@@@ -87,6 +90,13 @@ function createFetcher(config) {return { url: u, internal: false };}++ /** Judge a redirect from `from`: the same rules, and a public hop may not lead to the internal origin. */++ function follow(from, location) {++ const next = judge(location);++ if (next.internal && !from.internal) throw new AiError(422, 'fetch.refused', 'a redirect from a public host may not reach the internal Media origin');++ return next;++ }++function once(target, { signal, maxBytes, timeoutMs, toFile }) {return new Promise((resolve, reject) => {const { url, internal } = target;@@@ -139,10 +149,10 @@ function createFetcher(config) {async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null } = {}) {let target = judge(raw);for (let hop = 0; hop < 4; hop++) {-- const r = await once(target, { signal, maxBytes, timeoutMs, toFile });++ const r = await (transport || once)(target, { signal, maxBytes, timeoutMs, toFile });if (!r.redirect) return { ...r, url: target.url.toString() };if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');-- target = judge(r.redirect);++ target = follow(target, r.redirect);}throw new AiError(502, 'source.unavailable', 'too many redirects');}@@@ -200,7 +210,7 @@ function createFetcher(config) {return { file, url };}-- return { judge, fetchUrl, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };++ return { judge, follow, fetchUrl, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };}/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */@@@ -67,6 +67,39 @@ t.test('the internal Media origin is fetched; its redirect elsewhere is refused;assert.strictEqual(await refused(f, other), 'fetch.refused', 'another port on loopback is not the internal origin');});++t.test('every redirect hop is judged: an allow-listed host cannot redirect into the internal Media origin', async () => {++ const internal = 'http://127.0.0.1:4100';++ // A scripted transport: each URL answers with the redirect listed for it, or 200.++ const script = (hops) => {++ const seen = [];++ const transport = async (target) => {++ const u = target.url.toString();++ seen.push(u);++ return hops[u] ? { redirect: new URL(hops[u], u).toString() } : { contentType: 'image/png', bytes: 1, buffer: Buffer.from('x'), file: null };++ };++ return { seen, fetcher: createFetcher(load({ NODE_ENV: 'test', OV_MEDIA_INTERNAL_URL: internal }), { transport }) };++ };++ for (const to of [`${internal}/v/1`, `${internal}/f/secret`, 'http://127.0.0.1:4100/', 'http://169.254.169.254/latest/meta-data', 'https://127.0.0.1/x', 'https://evil.example/x', 'http://openvibe.media/x']) {++ const { seen, fetcher } = script({ 'https://openvibe.live/r': to });++ assert.strictEqual(await refused(fetcher, 'https://openvibe.live/r'), 'fetch.refused', to);++ assert.deepStrictEqual(seen, ['https://openvibe.live/r'], `nothing fetched after the refused hop (${to})`);++ }++ // Two public hops, then the internal origin: still refused.++ let s = script({ 'https://openvibe.live/a': 'https://openvibe.media/b', 'https://openvibe.media/b': `${internal}/img` });++ assert.strictEqual(await refused(s.fetcher, 'https://openvibe.live/a'), 'fetch.refused');++ assert.strictEqual(s.seen.length, 2);++ // Allowed chains: public -> public, internal -> internal, internal -> public.++ s = script({ 'https://openvibe.live/a': 'https://openvibe.media/b' });++ assert.strictEqual((await s.fetcher.fetchUrl('https://openvibe.live/a')).url, 'https://openvibe.media/b');++ s = script({ [`${internal}/a`]: '/b' });++ assert.strictEqual((await s.fetcher.fetchUrl(`${internal}/a`)).url, `${internal}/b`);++ s = script({ [`${internal}/a`]: 'https://openvibe.media/b' });++ assert.strictEqual((await s.fetcher.fetchUrl(`${internal}/a`)).url, 'https://openvibe.media/b');++ // Too many hops.++ s = script({ 'https://openvibe.live/1': '/2', 'https://openvibe.live/2': '/3', 'https://openvibe.live/3': '/4', 'https://openvibe.live/4': '/5' });++ assert.strictEqual(await refused(s.fetcher, 'https://openvibe.live/1'), 'source.unavailable');++});++t.test('a run whose image URL is not allowed fails with fetch.refused and no provider call', async () => {h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase } });const before = h.pool.stats.stub ? h.pool.stats.stub.calls : 0;
@@ -141,7 +141,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
@@ -141,7 +141,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
-| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and redirects re-checked; size caps | `test/ssrf.test.js` |
+| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
@@ -9,7 +9,9 @@
@@ -9,7 +9,9 @@
* - the address actually connected to must be public unicast: the DNS answer is checked inside
* - the address actually connected to must be public unicast: the DNS answer is checked inside
* the connection's own lookup (no second resolution, so no DNS rebinding), and IPv4-mapped
* the connection's own lookup (no second resolution, so no DNS rebinding), and IPv4-mapped
* IPv6 spellings of internal addresses are caught too
* IPv6 spellings of internal addresses are caught too
- * - redirects are followed by hand (max 3) and every hop is judged by the same rules
+ * - redirects are followed by hand (max 3) and every hop is judged by the same rules; the internal
+ * Media origin is reachable by redirect only from the internal origin itself, never from a
+ * public hop (an allow-listed host that redirects must not reach the loopback Media service)
* - a byte cap, a timeout and cancellation on every transfer
* - a byte cap, a timeout and cancellation on every transfer
* data: URLs (images a caller sends inline) are decoded locally with a size cap — never fetched.
* data: URLs (images a caller sends inline) are decoded locally with a size cap — never fetched.
* A MediaRef resolves to the public Media URL for the legacy kinds; med_ ids are an explicit
* A MediaRef resolves to the public Media URL for the legacy kinds; med_ ids are an explicit
@@ -71,7 +73,8 @@ function hostAllowed(hostname, allowHosts) {
@@ -71,7 +73,8 @@ function hostAllowed(hostname, allowHosts) {
});
});
}
}
-function createFetcher(config) {
+/** `transport` replaces the single-hop HTTP request (tests script redirect chains with it). */
+function createFetcher(config, { transport = null } = {}) {
const m = config.media;
const m = config.media;
const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;
const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;
@@ -87,6 +90,13 @@ function createFetcher(config) {
@@ -87,6 +90,13 @@ function createFetcher(config) {
return { url: u, internal: false };
return { url: u, internal: false };
}
}
+ /** Judge a redirect from `from`: the same rules, and a public hop may not lead to the internal origin. */
+ function follow(from, location) {
+ const next = judge(location);
+ if (next.internal && !from.internal) throw new AiError(422, 'fetch.refused', 'a redirect from a public host may not reach the internal Media origin');
+ return next;
+ }
+
function once(target, { signal, maxBytes, timeoutMs, toFile }) {
function once(target, { signal, maxBytes, timeoutMs, toFile }) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const { url, internal } = target;
const { url, internal } = target;
@@ -139,10 +149,10 @@ function createFetcher(config) {
@@ -139,10 +149,10 @@ function createFetcher(config) {
async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null } = {}) {
async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null } = {}) {
let target = judge(raw);
let target = judge(raw);
for (let hop = 0; hop < 4; hop++) {
for (let hop = 0; hop < 4; hop++) {
- const r = await once(target, { signal, maxBytes, timeoutMs, toFile });
+ const r = await (transport || once)(target, { signal, maxBytes, timeoutMs, toFile });
if (!r.redirect) return { ...r, url: target.url.toString() };
if (!r.redirect) return { ...r, url: target.url.toString() };
if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');
if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');
- target = judge(r.redirect);
+ target = follow(target, r.redirect);
}
}
throw new AiError(502, 'source.unavailable', 'too many redirects');
throw new AiError(502, 'source.unavailable', 'too many redirects');
}
}
@@ -200,7 +210,7 @@ function createFetcher(config) {
@@ -200,7 +210,7 @@ function createFetcher(config) {
return { file, url };
return { file, url };
}
}
- return { judge, fetchUrl, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };
+ return { judge, follow, fetchUrl, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };
}
}
/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */
/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */
@@ -67,6 +67,39 @@ t.test('the internal Media origin is fetched; its redirect elsewhere is refused;
@@ -67,6 +67,39 @@ t.test('the internal Media origin is fetched; its redirect elsewhere is refused;
assert.strictEqual(await refused(f, other), 'fetch.refused', 'another port on loopback is not the internal origin');
assert.strictEqual(await refused(f, other), 'fetch.refused', 'another port on loopback is not the internal origin');
});
});
+t.test('every redirect hop is judged: an allow-listed host cannot redirect into the internal Media origin', async () => {
+ const internal = 'http://127.0.0.1:4100';
+ // A scripted transport: each URL answers with the redirect listed for it, or 200.
+ const script = (hops) => {
+ const seen = [];
+ const transport = async (target) => {
+ const u = target.url.toString();
+ seen.push(u);
+ return hops[u] ? { redirect: new URL(hops[u], u).toString() } : { contentType: 'image/png', bytes: 1, buffer: Buffer.from('x'), file: null };
+ };
+ return { seen, fetcher: createFetcher(load({ NODE_ENV: 'test', OV_MEDIA_INTERNAL_URL: internal }), { transport }) };
+ };
+ for (const to of [`${internal}/v/1`, `${internal}/f/secret`, 'http://127.0.0.1:4100/', 'http://169.254.169.254/latest/meta-data', 'https://127.0.0.1/x', 'https://evil.example/x', 'http://openvibe.media/x']) {
+ const { seen, fetcher } = script({ 'https://openvibe.live/r': to });
+ assert.strictEqual(await refused(fetcher, 'https://openvibe.live/r'), 'fetch.refused', to);
+ assert.deepStrictEqual(seen, ['https://openvibe.live/r'], `nothing fetched after the refused hop (${to})`);
+ }
+ // Two public hops, then the internal origin: still refused.
+ let s = script({ 'https://openvibe.live/a': 'https://openvibe.media/b', 'https://openvibe.media/b': `${internal}/img` });
+ assert.strictEqual(await refused(s.fetcher, 'https://openvibe.live/a'), 'fetch.refused');
+ assert.strictEqual(s.seen.length, 2);
+ // Allowed chains: public -> public, internal -> internal, internal -> public.
+ s = script({ 'https://openvibe.live/a': 'https://openvibe.media/b' });
+ assert.strictEqual((await s.fetcher.fetchUrl('https://openvibe.live/a')).url, 'https://openvibe.media/b');
+ s = script({ [`${internal}/a`]: '/b' });
+ assert.strictEqual((await s.fetcher.fetchUrl(`${internal}/a`)).url, `${internal}/b`);
+ s = script({ [`${internal}/a`]: 'https://openvibe.media/b' });
+ assert.strictEqual((await s.fetcher.fetchUrl(`${internal}/a`)).url, 'https://openvibe.media/b');
+ // Too many hops.
+ s = script({ 'https://openvibe.live/1': '/2', 'https://openvibe.live/2': '/3', 'https://openvibe.live/3': '/4', 'https://openvibe.live/4': '/5' });
+ assert.strictEqual(await refused(s.fetcher, 'https://openvibe.live/1'), 'source.unavailable');
+});
+
t.test('a run whose image URL is not allowed fails with fetch.refused and no provider call', async () => {
t.test('a run whose image URL is not allowed fails with fetch.refused and no provider call', async () => {
h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase } });
h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase } });
const before = h.pool.stats.stub ? h.pool.stats.stub.calls : 0;
const before = h.pool.stats.stub ? h.pool.stats.stub.calls : 0;