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.
Changes
@@@ -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@@@ -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),@@@ -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;}@@@ -28,6 +28,22 @@ t.test('address policy: internal, loopback, link-local, mapped IPv6 are not publfor (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',
@@ -79,6 +79,8 @@ AI_MODEL=
@@ -79,6 +79,8 @@ AI_MODEL=
# media.analyze reads a recording where it lies through a loopback reader (server/media-proxy.js): nothing is
# 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).
# downloaded, and speech windows seek with range requests. 0 downloads it first (the disk cap then applies).
#AI_MEDIA_STREAM=1
#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_MODEL=qwen2.5-1.5b-instruct
#AI_LOCAL_LLM_TIMEOUT_MS=180000
#AI_LOCAL_LLM_TIMEOUT_MS=180000
# media.analyze: file cap (also bounded by free temp disk minus the reserve), download timeout, scene threshold
# media.analyze: file cap (also bounded by free temp disk minus the reserve), download timeout, scene threshold
@@ -185,6 +185,9 @@ function load(env = process.env) {
@@ -185,6 +185,9 @@ function load(env = process.env) {
internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
// https hostnames (exact, or *.suffix) that media/image URLs may point at.
// 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']),
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),
maxBytes: int(env.AI_FETCH_MAX_BYTES, 512 * 1024 * 1024),
maxImageBytes: int(env.AI_FETCH_MAX_IMAGE_BYTES, 12 * 1024 * 1024),
maxImageBytes: int(env.AI_FETCH_MAX_IMAGE_BYTES, 12 * 1024 * 1024),
timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
@@ -91,8 +91,24 @@ function createFetcher(config, { transport = null } = {}) {
@@ -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. */
/** 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) {
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');
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;
return next;
}
}
@@ -28,6 +28,22 @@ t.test('address policy: internal, loopback, link-local, mapped IPv6 are not publ
@@ -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);
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 () => {
t.test('URL rules: https + allow-listed host only', async () => {
const f = createFetcher(load({ NODE_ENV: 'test' }));
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',
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',