openvibers/openvibe.ai Commits

Offloaded recordings are readable: a redirect from Media to its object storage is followed (roadmap WS-O task 2). Most VODs live in B2 (676) or R2 (19), and Media answers /v/:id with a 302 to a presigned URL there. The fetcher refused those hosts, so a transcript of an offloaded VOD failed at the first window, found by the production check on private VOD 3780. The storage hosts (AI_MEDIA_STORAGE_HOSTS, default *.backblazeb2.com and *.r2.cloudflarestorage.com) are followed only as a redirect from Media itself (its public host or the internal origin), over https, with no credentials in the URL and no IP literal, with connect-time address checks as always. They are never a URL a caller may name. test/ssrf.test.js covers Media to B2 and to R2, and refuses another host sending there, naming storage directly, http, another host and credentials. npm test passes.

OP OpenVibers committed 28 Sept 2026 · 549bed0945 · 4 files, +38 −1


Changes

ddiff --git a/.env.example b/.env.example
iindex ff609db..93f27b7 100644
---- a/.env.example
++++ b/.env.example
@@@ -79,6 +79,8 @@ AI_MODEL=
# media.analyze reads a recording where it lies through a loopback reader (server/media-proxy.js): nothing is
# downloaded, and speech windows seek with range requests. 0 downloads it first (the disk cap then applies).
#AI_MEDIA_STREAM=1
++# Object storage Media redirects offloaded recordings to (presigned URLs), followed only from Media itself.
++#AI_MEDIA_STORAGE_HOSTS=*.backblazeb2.com,*.r2.cloudflarestorage.com
#AI_LOCAL_LLM_MODEL=qwen2.5-1.5b-instruct
#AI_LOCAL_LLM_TIMEOUT_MS=180000
# media.analyze: file cap (also bounded by free temp disk minus the reserve), download timeout, scene threshold
ddiff --git a/server/config.js b/server/config.js
iindex 27922ac..881d516 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -185,6 +185,9 @@ function load(env = process.env) {
internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
// https hostnames (exact, or *.suffix) that media/image URLs may point at.
allowHosts: list(env.AI_FETCH_ALLOW_HOSTS, ['openvibe.media', 'openvibe.live', 'openvibe.network', '*.openvibe.network', 'openvibe.community', 'openvibe.tools', 'openvibe.games']),
++ // Object storage Media hands recordings off to (a 302 to a presigned URL): reachable ONLY as a redirect
++ // from Media itself (its public host or the internal origin), never as a URL a caller names.
++ storageHosts: list(env.AI_MEDIA_STORAGE_HOSTS, ['*.backblazeb2.com', '*.r2.cloudflarestorage.com']),
maxBytes: int(env.AI_FETCH_MAX_BYTES, 512 * 1024 * 1024),
maxImageBytes: int(env.AI_FETCH_MAX_IMAGE_BYTES, 12 * 1024 * 1024),
timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
ddiff --git a/server/fetcher.js b/server/fetcher.js
iindex 724df5d..f99b713 100644
---- a/server/fetcher.js
++++ b/server/fetcher.js
@@@ -91,8 +91,24 @@ function createFetcher(config, { transport = null } = {}) {
}
/** Judge a redirect from `from`: the same rules, and a public hop may not lead to the internal origin. */
++ /** Media's own hosts: the internal origin, or its public URL's host. */
++ const mediaPublicHost = (() => { try { return new URL(m.publicUrl).hostname.toLowerCase(); } catch { return null; } })();
++ const fromMedia = (t) => t.internal || (mediaPublicHost && t.url.hostname.toLowerCase() === mediaPublicHost);
++
function follow(from, location) {
-- const next = judge(location);
++ let next;
++ try {
++ next = judge(location);
++ } catch (err) {
++ // Media hands an offloaded recording to object storage with a 302 to a presigned URL: that storage (and
++ // only it) is followed, only from Media, over https, never to an IP literal; connect-time checks still apply.
++ let u = null;
++ try { u = new URL(String(location)); } catch { /* not a URL */ }
++ const storage = u && fromMedia(from) && u.protocol === 'https:' && !u.username && !u.password
++ && !net.isIP(u.hostname.replace(/^\[|\]$/g, '')) && hostAllowed(u.hostname, m.storageHosts || []);
++ if (!storage) throw err;
++ return { url: u, internal: false };
++ }
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;
}
ddiff --git a/test/ssrf.test.js b/test/ssrf.test.js
iindex 2b99885..a897a82 100644
---- a/test/ssrf.test.js
++++ b/test/ssrf.test.js
@@@ -28,6 +28,22 @@ t.test('address policy: internal, loopback, link-local, mapped IPv6 are not publ
for (const ip of ['1.1.1.1', '93.184.216.34', '2606:4700:4700::1111']) assert.strictEqual(isPublicAddress(ip), true, ip);
});
++t.test('object storage is followed only as a redirect from Media', () => {
++ const f = createFetcher(load({ NODE_ENV: 'test', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:4100' }));
++ const media = { url: new URL('https://openvibe.media/v/1'), internal: false };
++ const internal = { url: new URL('http://127.0.0.1:4100/v/1'), internal: true };
++ const other = { url: new URL('https://openvibe.live/x'), internal: false };
++ const b2 = 'https://s3.us-west-004.backblazeb2.com/bucket/vod-1.mp4?X-Amz-Signature=abc';
++ assert.strictEqual(f.follow(media, b2).url.hostname, 's3.us-west-004.backblazeb2.com', 'Media -> B2');
++ assert.strictEqual(f.follow(internal, 'https://acct.r2.cloudflarestorage.com/b/x').internal, false, 'internal Media -> R2, as a public target');
++ const refused = (fn, why) => { try { fn(); assert.fail(why); } catch (e) { assert.strictEqual(e.code, 'fetch.refused', why); } };
++ refused(() => f.follow(other, b2), 'another host may not send it to storage');
++ refused(() => f.judge(b2), 'storage is never a URL a caller names');
++ refused(() => f.follow(media, 'http://s3.us-west-004.backblazeb2.com/x'), 'https only');
++ refused(() => f.follow(media, 'https://evil.example/x'), 'not storage');
++ refused(() => f.follow(media, 'https://user:[email protected]/x'), 'no credentials in the URL');
++});
++
t.test('URL rules: https + allow-listed host only', async () => {
const f = createFetcher(load({ NODE_ENV: 'test' }));
const bad = ['http://openvibe.media/v/1', 'https://evil.example/x', 'https://openvibe.media.evil.example/x', 'https://evilopenvibe.media/x', 'https://127.0.0.1/x',