openvibers/openvibe.locker Commits
Media downloads signed on the public origin are fetched over loopback (#2)
Changes
@@@ -14,6 +14,9 @@ function load(env = process.env) {networkUrl: env.OV_NETWORK_URL || 'https://openvibe.network',networkInternalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',mediaUrl: env.OV_MEDIA_URL || 'https://openvibe.media',++ // Media signs download URLs on its public origin; when Locker reaches Media over loopback, the same signed path++ // is fetched there (no service credentials follow either way).++ mediaPublicUrl: env.OV_MEDIA_PUBLIC_URL || 'https://openvibe.media',eventsUrl: env.OV_EVENTS_URL || 'https://openvibe.events',clientId: 'locker', clientSecret: env.OV_OAUTH_CLIENT_SECRET || '', eventsSecret: env.LOCKER_EVENTS_SECRET || '',dbUrl: env.DATABASE_URL, dbDirectUrl: env.DATABASE_DIRECT_URL,@@@ -42,8 +42,12 @@ function createMedia(config, fetchImpl = fetch) {async read(id) {// Keep private signed URLs on the server; never redirect a limited Send to Media.const { url } = await call('GET', `/${encodeURIComponent(id)}/download?format=json`);-- const target = new URL(url);-- if (target.origin !== new URL(config.mediaUrl).origin) fail(502, 'locker.media.origin', 'Unexpected encrypted storage origin.');++ let target = new URL(url);++ const internal = new URL(config.mediaUrl);++ if (config.mediaPublicUrl && target.origin === new URL(config.mediaPublicUrl).origin && target.origin !== internal.origin) {++ target = new URL(`${target.pathname}${target.search}`, internal.origin);++ }++ if (target.origin !== internal.origin) fail(502, 'locker.media.origin', 'Unexpected encrypted storage origin.');// Media may redirect its private signed URL to a signed storage-provider URL; no service credentials follow.const r = await fetchImpl(target, { signal: AbortSignal.timeout(30 * 60 * 1000), redirect: 'follow' });if (!r.ok || !r.body) fail(502, 'locker.media.read', 'Encrypted storage is temporarily unavailable.');@@@ -52,3 +52,21 @@ test('Network key proxy preserves reauthentication errors without fetching serviawait assert.rejects(network.json({ method: 'PUT', path: '/api/v1/me/keys', json: {}, token: 'test-session', preserveProblem: true }),(e) => e.status === 403 && e.code === 'locker.reauth_required');});++++test('a download signed on the public Media origin is fetched over the loopback origin; any other origin is refused', async () => {++ const seen = [];++ const json = (body) => Response.json(body);++ let signed = 'https://media.public/o/med_x?sig=abc';++ const fakeFetch = async (url, options) => {++ const u = new URL(url); seen.push(u.href);++ if (u.pathname === '/oauth/token') return json({ access_token: 'test-only', expires_in: 300 });++ if (u.pathname.endsWith('/download')) return json({ url: signed });++ assert.equal(options.headers, undefined, 'no service credentials follow a signed URL');++ return new Response(Buffer.from('ciphertext'));++ };++ const media = createMedia({ networkInternalUrl: 'https://network.test', mediaUrl: 'http://127.0.0.1:4100', mediaPublicUrl: 'https://media.public', clientId: 'locker', clientSecret: '' }, fakeFetch);++ assert.equal(Buffer.from(await (await media.read('med_x')).arrayBuffer()).toString(), 'ciphertext');++ assert.ok(seen.includes('http://127.0.0.1:4100/o/med_x?sig=abc'), 'the signed path went to loopback');++ signed = 'https://elsewhere.example/o/med_x?sig=abc';++ await assert.rejects(media.read('med_x'), (e) => e.code === 'locker.media.origin');++});
@@ -14,6 +14,9 @@ function load(env = process.env) {
@@ -14,6 +14,9 @@ function load(env = process.env) {
networkUrl: env.OV_NETWORK_URL || 'https://openvibe.network',
networkUrl: env.OV_NETWORK_URL || 'https://openvibe.network',
networkInternalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
networkInternalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
mediaUrl: env.OV_MEDIA_URL || 'https://openvibe.media',
mediaUrl: env.OV_MEDIA_URL || 'https://openvibe.media',
+ // Media signs download URLs on its public origin; when Locker reaches Media over loopback, the same signed path
+ // is fetched there (no service credentials follow either way).
+ mediaPublicUrl: env.OV_MEDIA_PUBLIC_URL || 'https://openvibe.media',
eventsUrl: env.OV_EVENTS_URL || 'https://openvibe.events',
eventsUrl: env.OV_EVENTS_URL || 'https://openvibe.events',
clientId: 'locker', clientSecret: env.OV_OAUTH_CLIENT_SECRET || '', eventsSecret: env.LOCKER_EVENTS_SECRET || '',
clientId: 'locker', clientSecret: env.OV_OAUTH_CLIENT_SECRET || '', eventsSecret: env.LOCKER_EVENTS_SECRET || '',
dbUrl: env.DATABASE_URL, dbDirectUrl: env.DATABASE_DIRECT_URL,
dbUrl: env.DATABASE_URL, dbDirectUrl: env.DATABASE_DIRECT_URL,
@@ -42,8 +42,12 @@ function createMedia(config, fetchImpl = fetch) {
@@ -42,8 +42,12 @@ function createMedia(config, fetchImpl = fetch) {
async read(id) {
async read(id) {
// Keep private signed URLs on the server; never redirect a limited Send to Media.
// Keep private signed URLs on the server; never redirect a limited Send to Media.
const { url } = await call('GET', `/${encodeURIComponent(id)}/download?format=json`);
const { url } = await call('GET', `/${encodeURIComponent(id)}/download?format=json`);
- const target = new URL(url);
- if (target.origin !== new URL(config.mediaUrl).origin) fail(502, 'locker.media.origin', 'Unexpected encrypted storage origin.');
+ let target = new URL(url);
+ const internal = new URL(config.mediaUrl);
+ if (config.mediaPublicUrl && target.origin === new URL(config.mediaPublicUrl).origin && target.origin !== internal.origin) {
+ target = new URL(`${target.pathname}${target.search}`, internal.origin);
+ }
+ if (target.origin !== internal.origin) fail(502, 'locker.media.origin', 'Unexpected encrypted storage origin.');
// Media may redirect its private signed URL to a signed storage-provider URL; no service credentials follow.
// Media may redirect its private signed URL to a signed storage-provider URL; no service credentials follow.
const r = await fetchImpl(target, { signal: AbortSignal.timeout(30 * 60 * 1000), redirect: 'follow' });
const r = await fetchImpl(target, { signal: AbortSignal.timeout(30 * 60 * 1000), redirect: 'follow' });
if (!r.ok || !r.body) fail(502, 'locker.media.read', 'Encrypted storage is temporarily unavailable.');
if (!r.ok || !r.body) fail(502, 'locker.media.read', 'Encrypted storage is temporarily unavailable.');
@@ -52,3 +52,21 @@ test('Network key proxy preserves reauthentication errors without fetching servi
@@ -52,3 +52,21 @@ test('Network key proxy preserves reauthentication errors without fetching servi
await assert.rejects(network.json({ method: 'PUT', path: '/api/v1/me/keys', json: {}, token: 'test-session', preserveProblem: true }),
await assert.rejects(network.json({ method: 'PUT', path: '/api/v1/me/keys', json: {}, token: 'test-session', preserveProblem: true }),
(e) => e.status === 403 && e.code === 'locker.reauth_required');
(e) => e.status === 403 && e.code === 'locker.reauth_required');
});
});
+
+test('a download signed on the public Media origin is fetched over the loopback origin; any other origin is refused', async () => {
+ const seen = [];
+ const json = (body) => Response.json(body);
+ let signed = 'https://media.public/o/med_x?sig=abc';
+ const fakeFetch = async (url, options) => {
+ const u = new URL(url); seen.push(u.href);
+ if (u.pathname === '/oauth/token') return json({ access_token: 'test-only', expires_in: 300 });
+ if (u.pathname.endsWith('/download')) return json({ url: signed });
+ assert.equal(options.headers, undefined, 'no service credentials follow a signed URL');
+ return new Response(Buffer.from('ciphertext'));
+ };
+ const media = createMedia({ networkInternalUrl: 'https://network.test', mediaUrl: 'http://127.0.0.1:4100', mediaPublicUrl: 'https://media.public', clientId: 'locker', clientSecret: '' }, fakeFetch);
+ assert.equal(Buffer.from(await (await media.read('med_x')).arrayBuffer()).toString(), 'ciphertext');
+ assert.ok(seen.includes('http://127.0.0.1:4100/o/med_x?sig=abc'), 'the signed path went to loopback');
+ signed = 'https://elsewhere.example/o/med_x?sig=abc';
+ await assert.rejects(media.read('med_x'), (e) => e.code === 'locker.media.origin');
+});