Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/cloudflare-workers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ name: Cloudflare workers
on:
push:
branches: [main]
paths: ["cloudflare-workers/**"]
paths:
- "cloudflare-workers/**"
- "backblaze-proxy.sh"
- "utils/signed_clip_links.sh"
pull_request:
paths: ["cloudflare-workers/**"]
paths:
- "cloudflare-workers/**"
- "backblaze-proxy.sh"
- "utils/signed_clip_links.sh"

jobs:
test:
Expand Down
29 changes: 29 additions & 0 deletions backblaze-proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
# Safe to run again; it updates the worker in place and keeps any routes it
# already has on other hostnames, so older links keep working.
#
# The worker starts refusing a clip or lineup file without a signed link once
# the panel's database shows its API signing them with the S3_SECRET the worker
# gets (utils/signed_clip_links.sh). Once it does, a run that cannot reach the
# panel keeps it on, and only an explicit yes turns it off: one worker can serve
# several panels. Run it again, where kubectl reaches the panel, after every
# panel update or rollback and after changing S3_SECRET.
#
# ./backblaze-proxy.sh [hostname]

PANEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -27,6 +34,7 @@ source "$PANEL_DIR/utils/interactive_select.sh"
source "$PANEL_DIR/utils/print_domains_and_hosts.sh"
source "$PANEL_DIR/utils/update_env_var.sh"
source "$PANEL_DIR/utils/cloudflare_workers.sh"
source "$PANEL_DIR/utils/signed_clip_links.sh"

# Existing Cloudflare routes point at this name; renaming spawns a second
# worker while the old one keeps serving.
Expand Down Expand Up @@ -270,13 +278,34 @@ if [ -n "$API_DOMAIN" ]; then
VARS+=(--var "API_URL:https://$API_DOMAIN")
fi

step "Checking that the panel signs clip links"
SIGNED_CLIPS_REQUIRED=false
require_signed_clip_links \
"$(worker_signed_clip_links "$WORKER_NAME")" \
"$(signed_clip_links_state "$S3_SECRET")"
case "$?" in
0)
SIGNED_CLIPS_REQUIRED=true
VARS+=(--var "REQUIRE_SIGNED_CLIPS:1")
;;
1) ;;
*)
die "Nothing deployed, so the worker is as it was."
;;
esac

step "Ready to deploy"
ok "Worker: $WORKER_NAME"
ok "Hostname: https://$WORKER_HOST (${ROUTE_PATHS[*]})"
ok "Bucket: $S3_BUCKET at $S3_ENDPOINT"
if [ -n "$API_DOMAIN" ]; then
ok "API: https://$API_DOMAIN (clip views are counted there)"
fi
if [ "$SIGNED_CLIPS_REQUIRED" = true ]; then
ok "Clips: signed links required"
else
warn "Clips: signed links not required"
fi
read -r -p " Deploy it? [Y/n] " CONFIRM
if [[ "$CONFIRM" =~ ^[Nn] ]]; then
warn "Nothing deployed."
Expand Down
251 changes: 243 additions & 8 deletions cloudflare-workers/backblaze-proxy/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import worker from "./index.ts";

const env = {
Expand All @@ -10,6 +11,18 @@ const env = {
};
const ORIGIN = "https://5stack.gg";

// The query string the api's computed fields put on a clip link
// (file_url_signature.sql), worked out the way the api does it.
function signed(key: string, exp = Math.floor(Date.now() / 1000) + 21600) {
const signingKey = createHmac("sha256", env.S3_SECRET)
.update("5stack-file-url-v1")
.digest();
const sig = createHmac("sha256", signingKey)
.update(`${key}\n${exp}`)
.digest("base64url");
return `exp=${exp}&sig=${sig}`;
}

const realFetch = globalThis.fetch;
let upstream: ReturnType<typeof mock.fn>;

Expand Down Expand Up @@ -107,7 +120,9 @@ describe("backblaze-proxy upstream failures", () => {
);
globalThis.fetch = upstream as unknown as typeof fetch;

const { response } = await get("clips/missing.mp4");
const { response } = await get(
`clips/missing.mp4?${signed("clips/missing.mp4")}`,
);

assert.equal(upstream.mock.callCount(), 3);
assert.equal(response.status, 403);
Expand Down Expand Up @@ -170,6 +185,7 @@ describe("backblaze-proxy health", () => {
describe("backblaze-proxy clip views", () => {
const API_URL = "https://api.5stack.gg";
const CLIP = "clips/76561198000000001/0b7c.mp4";
const SIGNED = signed(CLIP);
const UA = "Mozilla/5.0 (Macintosh) Chrome/141";

let waited: Promise<unknown>[];
Expand Down Expand Up @@ -222,29 +238,29 @@ describe("backblaze-proxy clip views", () => {
});

it("counts a played clip", async () => {
await send("GET", `${CLIP}?name=ace.mp4&v=1`);
await send("GET", `${CLIP}?name=ace.mp4&v=1&${SIGNED}`);

assert.equal(viewCalls().length, 1);
});

it("does not count a request marked noview", async () => {
await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1`);
await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1&${SIGNED}`);

assert.equal(viewCalls().length, 0);
});

it("keeps one edge copy of a clip whether or not the request is marked noview", async () => {
await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1`);
await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1&${SIGNED}`);
const marked = [...cacheKeys];
cacheKeys = [];
await send("GET", `${CLIP}?name=ace.mp4&v=1`);
await send("GET", `${CLIP}?name=ace.mp4&v=1&${SIGNED}`);

assert.deepEqual(marked, cacheKeys);
assert.ok(cacheKeys.every((key) => !key.includes("noview")));
});

it("drops noview from the cache key without re-encoding the rest", async () => {
await send("GET", `${CLIP}?name=ace%20on%20B.mp4&noview=1&v=1`);
await send("GET", `${CLIP}?name=ace%20on%20B.mp4&noview=1&v=1&${SIGNED}`);

assert.ok(
cacheKeys.every(
Expand All @@ -256,10 +272,10 @@ describe("backblaze-proxy clip views", () => {
});

it("still keeps a named download apart from the inline copy", async () => {
await send("GET", `${CLIP}?name=ace.mp4&v=1&dl=1`);
await send("GET", `${CLIP}?name=ace.mp4&v=1&dl=1&${SIGNED}`);
const download = [...cacheKeys];
cacheKeys = [];
await send("GET", `${CLIP}?name=ace.mp4&v=1`);
await send("GET", `${CLIP}?name=ace.mp4&v=1&${SIGNED}`);

assert.notDeepEqual(download, cacheKeys);
});
Expand All @@ -284,3 +300,222 @@ describe("backblaze-proxy clip views", () => {
);
});
});

describe("backblaze-proxy signed clip links", () => {
const CLIP = "clips/76561198000000001/0b7c.mp4";
const LINEUP_STILL =
"clips/utility/ffe58c95-4581-4320-a14e-f2efb95ccebe/aim.jpg";

let cacheKeys: string[];
let cached: Response | undefined;
let requireSigned: boolean;

beforeEach(() => {
cacheKeys = [];
cached = undefined;
requireSigned = true;
(globalThis as any).caches = {
default: {
match: async (request: Request) => {
cacheKeys.push(request.url);
return cached;
},
put: async (request: Request) => {
cacheKeys.push(request.url);
},
},
};
});

async function send(
path: string,
init: { method?: string; headers?: Record<string, string> } = {},
) {
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
return worker.fetch(
new Request(`https://cf.5stack.gg/${path}`, {
method: init.method ?? "GET",
headers: { Origin: ORIGIN, ...init.headers },
}),
requireSigned ? { ...env, REQUIRE_SIGNED_CLIPS: "1" } : env,
ctx as any,
);
}

async function refused(path: string, method = "GET") {
const response = await send(path, { method });

assert.equal(response.status, 403);
assert.equal(response.headers.get("Cache-Control"), "no-store");
assert.equal(response.headers.get("Access-Control-Allow-Origin"), ORIGIN);
assert.equal(upstream.mock.callCount(), 0);
assert.deepEqual(cacheKeys, []);
}

const now = () => Math.floor(Date.now() / 1000);

it("refuses a clip without a signature, before the cache or B2", async () => {
await refused(`${CLIP}?name=ace.mp4&v=1`);
});

it("refuses the HEAD a player sends first, too", async () => {
await refused(CLIP, "HEAD");
});

it("refuses a utility lineup file without a signature", async () => {
await refused(`${LINEUP_STILL}?v=1`);
});

it("refuses a clip named by ?file= on another route", async () => {
await refused(`demo?file=${encodeURIComponent(CLIP)}`);
});

it("refuses an expired link", async () => {
await refused(`${CLIP}?${signed(CLIP, now() - 1)}`);
});

it("refuses a link signed for another file", async () => {
await refused(`${CLIP}?${signed(LINEUP_STILL)}`);
});

it("refuses a link whose expiry was pushed out", async () => {
const sig = new URLSearchParams(signed(CLIP)).get("sig");
await refused(`${CLIP}?exp=${now() + 365 * 86400}&sig=${sig}`);
});

it("refuses a forged signature", async () => {
await refused(`${CLIP}?exp=${now() + 600}&sig=${"A".repeat(43)}`);
});

it("refuses a link signed with another bucket's secret", async () => {
const exp = now() + 600;
const otherKey = createHmac("sha256", "another-secret")
.update("5stack-file-url-v1")
.digest();
const sig = createHmac("sha256", otherKey)
.update(`${CLIP}\n${exp}`)
.digest("base64url");
await refused(`${CLIP}?exp=${exp}&sig=${sig}`);
});

for (const path of [
`maps/%252e%252e/${CLIP}`,
`maps//${CLIP}`,
`maps/%5C..%5C${CLIP}`,
`demo?file=${encodeURIComponent(`/${CLIP}`)}`,
]) {
it(`refuses ${path}, which B2 could resolve into clips/`, async () => {
await refused(path);
});
}

it("does not hand a cached copy of a clip to a request without a signature", async () => {
cached = new Response("cached clip", { status: 200 });

const response = await send(CLIP);

assert.equal(response.status, 403);
assert.deepEqual(cacheKeys, []);
});

it("serves a clip on a link the api signed, by the key B2 holds it under", async () => {
const response = await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP)}`);

assert.equal(response.status, 200);
assert.equal(response.headers.get("Content-Type"), "video/mp4");
assert.equal(
response.headers.get("Content-Disposition"),
'inline; filename="ace.mp4"',
);
assert.equal(
String(upstream.mock.calls[0].arguments[0]),
`https://5stack.s3.example.test/${CLIP}`,
);
});

it("serves a signed link as a named download when asked", async () => {
const response = await send(`${CLIP}?${signed(CLIP)}&dl=1&name=ace.mp4`);

assert.equal(response.status, 200);
assert.equal(
response.headers.get("Content-Disposition"),
'attachment; filename="ace.mp4"',
);
});

it("answers a range on a signed link out of the cached copy", async () => {
cached = new Response("ace", {
status: 206,
headers: { "Content-Range": "bytes 0-2/100" },
});

const response = await send(`${CLIP}?v=1&${signed(CLIP)}`, {
headers: { Range: "bytes=0-2" },
});

assert.equal(response.status, 206);
assert.equal(upstream.mock.callCount(), 0);
});

it("keeps one edge copy of a file for every link signed for it", async () => {
await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP, now() + 21600)}`);
const first = [...cacheKeys];
cacheKeys = [];
await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP, now() + 43200)}`);

assert.deepEqual(first, cacheKeys);
assert.ok(
cacheKeys.every(
(key) =>
key ===
`https://cf.5stack.gg/${CLIP}?name=ace.mp4&v=1#origin=${ORIGIN}`,
),
);
});

for (const path of [
"maps/25537370/manifest.json",
"news/9a3c.webp",
"events/4f1e/banner.jpg",
"demo/0b7c.dem",
]) {
it(`serves ${path} without a signature`, async () => {
const response = await send(path);

assert.equal(response.status, 200);
assert.equal(upstream.mock.callCount(), 1);
});
}

// Deployed this way until ./backblaze-proxy.sh has seen the panel's api sign
// with this S3_SECRET: enforcing in front of an api that does not would
// refuse every clip.
describe("without REQUIRE_SIGNED_CLIPS", () => {
beforeEach(() => {
requireSigned = false;
});

for (const [label, path] of [
["a clip without a signature", `${CLIP}?name=ace.mp4&v=1`],
["a lineup file without a signature", `${LINEUP_STILL}?v=1`],
["an expired link", `${CLIP}?${signed(CLIP, now() - 1)}`],
["a forged link", `${CLIP}?exp=${now() + 600}&sig=${"A".repeat(43)}`],
]) {
it(`serves ${label}, as before signed links`, async () => {
const response = await send(path);

assert.equal(response.status, 200);
assert.equal(upstream.mock.callCount(), 1);
});
}

it("keeps one edge copy of a clip for its signed and unsigned links", async () => {
await send(`${CLIP}?name=ace.mp4&v=1`);
const unsigned = [...cacheKeys];
cacheKeys = [];
await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP)}`);

assert.deepEqual(unsigned, cacheKeys);
});
});
});
Loading
Loading