Skip to content

Commit

Permalink
api: access-control: allowedOrigins (#2176)
Browse files Browse the repository at this point in the history
* api: access-control: allowedOrigins

* api: access-control: allowedOrigins

* fix tests

* wildcard + more checks

* is wildcard

* tests

* address commet
  • Loading branch information
gioelecerati authored May 20, 2024
1 parent 9831ccf commit 14351dd
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions packages/api/src/controllers/access-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
22 changes: 22 additions & 0 deletions packages/api/src/controllers/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ app.post(
}

const playbackPolicyType = content.playbackPolicy?.type ?? "public";
const allowedOrigins = content.playbackPolicy?.allowedOrigins ?? [];

let config: Partial<GateConfig> = {};

Expand All @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions packages/api/src/controllers/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scheme>://<hostname>:<port>"
);
}
}
}
} catch (err) {
console.log(`Error validating allowedOrigins: ${err}`);
}
}
}
if (encryption?.encryptedKey) {
if (!playbackPolicy) {
Expand Down
29 changes: 29 additions & 0 deletions packages/api/src/controllers/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scheme>://<hostname>:<port>"
);
}
}
}
} catch (err) {
console.log(`Error validating allowedOrigins: ${err}`);
}
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/api/src/schema/api-schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
(<scheme>://<hostname>:<port>, <scheme>://<hostname>)
items:
type: string
usage-metric:
type: object
description: |
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/schema/db-schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 14351dd

Please sign in to comment.