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)

OP OpenVibers committed 23 Sept 2026 · 690b06820c · 3 files, +49 −6


Changes

ddiff --git a/README.md b/README.md
iindex e1a1a75..ac33315 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/server/fetcher.js b/server/fetcher.js
iindex 279be38..ac7acd7 100644
---- a/server/fetcher.js
++++ b/server/fetcher.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. */
ddiff --git a/test/ssrf.test.js b/test/ssrf.test.js
iindex 391a93e..2b99885 100644
---- a/test/ssrf.test.js
++++ b/test/ssrf.test.js
@@@ -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;