diff --git a/packages/api/package.json b/packages/api/package.json index 3fc807936..7681f1544 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -33,6 +33,7 @@ "go-livepeer:broadcaster": "bin/livepeer -broadcaster -datadir ./bin/broadcaster -orchAddr 127.0.0.1:3086 -rtmpAddr 0.0.0.0:3035 -httpAddr :3085 -cliAddr :3075 -v 6 -authWebhookUrl http://127.0.0.1:3004/api/stream/hook -orchWebhookUrl http://127.0.0.1:3004/api/orchestrator", "go-livepeer:orchestrator": "bin/livepeer -orchestrator -datadir ./bin/orchestrator -transcoder -serviceAddr 127.0.0.1:3086 -cliAddr :3076 -v 6", "test": "POSTGRES_CONNECT_TIMEOUT=120000 jest -i --silent \"${PWD}/src\"", + "test-single": "POSTGRES_CONNECT_TIMEOUT=120000 jest -i --silent \"${PWD}/src/controllers/$filename\"", "test:dev": "jest \"${PWD}/src\" -i --silent --watch", "test:build": "parcel build --no-autoinstall --no-minify --bundle-node-modules -t browser --out-dir ../dist-worker ../src/worker.js", "coverage": "yarn run test --coverage", diff --git a/packages/api/src/controllers/access-control.test.ts b/packages/api/src/controllers/access-control.test.ts index 2f7855b65..683eae64b 100644 --- a/packages/api/src/controllers/access-control.test.ts +++ b/packages/api/src/controllers/access-control.test.ts @@ -242,6 +242,92 @@ describe("controllers/access-control", () => { expect(res.status).toBe(404); }); + it("should not allow playback if origin is not in playback.allowedOrigins", async () => { + gatedAsset.playbackPolicy.type = "jwt"; + gatedAsset.playbackPolicy.allowedOrigins = ["http://localhost:3000"]; + await db.asset.update(gatedAsset.id, { + playbackPolicy: gatedAsset.playbackPolicy, + }); + let asset = await db.asset.get(gatedAsset.id); + expect(asset.playbackPolicy.allowedOrigins).toEqual([ + "http://localhost:3000", + ]); + const res3 = await client.post("/access-control/gate", { + stream: `video+${gatedAsset.playbackId}`, + type: "jwt", + pub: "notExistingPubKey", + webhookPayload: { + headers: { + origin: "https://example.com", + }, + }, + }); + expect(res3.status).toBe(403); + let resJson = await res3.json(); + expect(resJson.errors[0]).toBe( + `Content is gated and origin not in allowed origins` + ); + const res4 = await client.post("/access-control/gate", { + stream: `video+${gatedAsset.playbackId}`, + type: "jwt", + pub: "notExistingPubKey", + webhookPayload: { + headers: { + origin: "http://localhost:3000", + }, + }, + }); + expect(res4.status).toBe(403); + let resJson2 = await res4.json(); + expect(resJson2.errors[0]).toBe( + "Content is gated and corresponding public key not found" + ); + }); + + it("should allow playback with corresponding origin", async () => { + gatedAsset.playbackPolicy.type = "jwt"; + gatedAsset.playbackPolicy.allowedOrigins = ["http://localhost:3000"]; + await db.asset.update(gatedAsset.id, { + playbackPolicy: gatedAsset.playbackPolicy, + }); + let asset = await db.asset.get(gatedAsset.id); + expect(asset.playbackPolicy.allowedOrigins).toEqual([ + "http://localhost:3000", + ]); + const res = await client.post("/access-control/gate", { + stream: `video+${gatedAsset.playbackId}`, + type: "jwt", + pub: signingKey.publicKey, + webhookPayload: { + headers: { + origin: "http://localhost:3000", + }, + }, + }); + expect(res.status).toBe(200); + }); + + it("should allow playback with wildcard origin", async () => { + gatedAsset.playbackPolicy.type = "jwt"; + gatedAsset.playbackPolicy.allowedOrigins = ["*"]; + await db.asset.update(gatedAsset.id, { + playbackPolicy: gatedAsset.playbackPolicy, + }); + let asset = await db.asset.get(gatedAsset.id); + expect(asset.playbackPolicy.allowedOrigins).toEqual(["*"]); + const res = await client.post("/access-control/gate", { + stream: `video+${gatedAsset.playbackId}`, + type: "jwt", + pub: signingKey.publicKey, + webhookPayload: { + headers: { + origin: "http://localhost:3000", + }, + }, + }); + expect(res.status).toBe(200); + }); + it("should allow playback on public playbackId with and without a public key provided", async () => { client.jwtAuth = adminToken; const res = await client.post("/access-control/gate", { diff --git a/packages/api/src/controllers/access-control.ts b/packages/api/src/controllers/access-control.ts index 9e74c6d63..c0b4b519f 100644 --- a/packages/api/src/controllers/access-control.ts +++ b/packages/api/src/controllers/access-control.ts @@ -219,6 +219,7 @@ app.post( } const playbackPolicyType = content.playbackPolicy?.type ?? "public"; + const allowedOrigins = content.playbackPolicy?.allowedOrigins ?? []; let config: Partial = {}; @@ -242,6 +243,27 @@ app.post( return res.end(); } + const origin = req.body?.webhookPayload?.headers?.origin; + + if (origin) { + if (allowedOrigins.length > 0) { + if (allowedOrigins.includes("*")) { + console.log(` + access-control: gate: content with playbackId=${playbackId} is gated, wildcard origin allowed + `); + } else { + if (!allowedOrigins.includes(origin)) { + console.log(` + access-control: gate: content with playbackId=${playbackId} is gated but origin=${origin} not in allowed origins=${allowedOrigins}, disallowing playback + `); + throw new ForbiddenError( + `Content is gated and origin not in allowed origins` + ); + } + } + } + } + switch (playbackPolicyType) { case "public": res.status(200); diff --git a/packages/api/src/controllers/asset.ts b/packages/api/src/controllers/asset.ts index 82fb5017c..b041a8894 100644 --- a/packages/api/src/controllers/asset.ts +++ b/packages/api/src/controllers/asset.ts @@ -287,6 +287,35 @@ async function validateAssetPlaybackPolicy( `webhook ${playbackPolicy.webhookId} not found` ); } + const allowedOrigins = playbackPolicy?.allowedOrigins; + if (allowedOrigins) { + try { + if (allowedOrigins.length > 0) { + const isWildcardOrigin = + allowedOrigins.length === 1 && allowedOrigins[0] === "*"; + if (!isWildcardOrigin) { + const isValidOrigin = (origin) => { + if (origin.endsWith("/")) return false; + const url = new URL(origin); + return ( + ["http:", "https:"].includes(url.protocol) && + url.hostname && + (url.port === "" || Number(url.port) > 0) && + url.pathname === "" + ); + }; + const allowedOriginsValid = allowedOrigins.every(isValidOrigin); + if (!allowedOriginsValid) { + throw new BadRequestError( + "allowedOrigins must be a list of valid origins ://:" + ); + } + } + } + } catch (err) { + console.log(`Error validating allowedOrigins: ${err}`); + } + } } if (encryption?.encryptedKey) { if (!playbackPolicy) { diff --git a/packages/api/src/controllers/stream.ts b/packages/api/src/controllers/stream.ts index c871b7d3c..6ad858294 100644 --- a/packages/api/src/controllers/stream.ts +++ b/packages/api/src/controllers/stream.ts @@ -206,6 +206,35 @@ async function validateStreamPlaybackPolicy( `webhook ${playbackPolicy.webhookId} not found` ); } + const allowedOrigins = playbackPolicy?.allowedOrigins; + if (allowedOrigins) { + try { + if (allowedOrigins.length > 0) { + const isWildcardOrigin = + allowedOrigins.length === 1 && allowedOrigins[0] === "*"; + if (!isWildcardOrigin) { + const isValidOrigin = (origin) => { + if (origin.endsWith("/")) return false; + const url = new URL(origin); + return ( + ["http:", "https:"].includes(url.protocol) && + url.hostname && + (url.port === "" || Number(url.port) > 0) && + url.pathname === "" + ); + }; + const allowedOriginsValid = allowedOrigins.every(isValidOrigin); + if (!allowedOriginsValid) { + throw new BadRequestError( + "allowedOrigins must be a list of valid origins ://:" + ); + } + } + } + } catch (err) { + console.log(`Error validating allowedOrigins: ${err}`); + } + } } } diff --git a/packages/api/src/schema/api-schema.yaml b/packages/api/src/schema/api-schema.yaml index f9dceb565..5f541c521 100644 --- a/packages/api/src/schema/api-schema.yaml +++ b/packages/api/src/schema/api-schema.yaml @@ -2282,6 +2282,13 @@ components: Interval (in seconds) at which the playback policy should be refreshed (default 600 seconds) example: 600 + allowedOrigins: + type: array + description: + List of allowed origins for CORS playback + (://:, ://) + items: + type: string usage-metric: type: object description: | diff --git a/packages/api/src/schema/db-schema.yaml b/packages/api/src/schema/db-schema.yaml index fd009ce09..f4db7ce53 100644 --- a/packages/api/src/schema/db-schema.yaml +++ b/packages/api/src/schema/db-schema.yaml @@ -37,6 +37,9 @@ components: webhookHeaders: type: object description: Headers to be used in the request to the webhook + origin: + type: string + description: Origin url requesting playback object-store-patch-payload: type: object additionalProperties: false