diff --git a/camo/src/index.js b/camo/src/index.js index a9b9893f8..9bd679818 100644 --- a/camo/src/index.js +++ b/camo/src/index.js @@ -1,3 +1,16 @@ +const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"]; +const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/; + +// only public, named, http(s) hosts; single-label names are compose/k8s +// service names +const signable = (url) => { + const host = url.hostname.toLowerCase(); + // ipv6 literals keep their colons in hostname + if (host === "" || host.includes(":") || IPV4.test(host)) return false; + if (!host.includes(".")) return false; + return !BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix)); +}; + export default { async fetch(request, env) { const url = new URL(request.url); @@ -55,13 +68,6 @@ export default { return new Response("Invalid signature", { status: 403 }); } - // check if we have an entry in the cache with the target url - let cacheKey = new Request(targetUrl); - let response = await cache.match(cacheKey); - if (response) { - return response; - } - let parsedUrl; try { parsedUrl = new URL(targetUrl); @@ -72,6 +78,18 @@ export default { return new Response("Malformed URL", { status: 400 }); } + // the signer applies the same policy but re-verify at the edge: signatures + // outlive policy changes and the two deploy independently + if (!signable(parsedUrl)) { + return new Response("Blocked host", { status: 403 }); + } + + let cacheKey = new Request(targetUrl); + let response = await cache.match(cacheKey); + if (response) { + return response; + } + // fetch from the parsed URL const res = await fetch(parsedUrl.toString(), { headers: { "User-Agent": "Tangled Camo v0.1.0" }, diff --git a/web/src/lib/server/camotarget.test.ts b/web/src/lib/server/camotarget.test.ts new file mode 100644 index 000000000..c2a6e9e71 --- /dev/null +++ b/web/src/lib/server/camotarget.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { signableTarget } from "./camotarget"; + +const target = (url: string) => signableTarget(new URL(url)); + +describe("signableTarget", () => { + it("allows public named http(s) hosts", () => { + expect(target("https://example.com/a.png")).toBe(true); + expect(target("http://i.imgcdn.test/a/b.png")).toBe(true); + expect(target("https://mirror.example.co.uk:8443/x.png")).toBe(true); + }); + + it("blocks ip literals", () => { + expect(target("http://127.0.0.1:8090/x.png")).toBe(false); + expect(target("http://10.0.0.9/x.png")).toBe(false); + expect(target("http://[::1]/x.png")).toBe(false); + expect(target("http://[fd00::1]/x.png")).toBe(false); + }); + + it("blocks single-label service names", () => { + expect(target("http://bobbin:8090/x.png")).toBe(false); + expect(target("http://knot/x.png")).toBe(false); + }); + + it("blocks internal-use suffixes", () => { + expect(target("http://bobbin.internal/x.png")).toBe(false); + expect(target("http://app.localhost/x.png")).toBe(false); + expect(target("http://nas.local/x.png")).toBe(false); + expect(target("http://vm.home.arpa/x.png")).toBe(false); + }); + + it("rejects non-http(s) schemes upstream of the host check", () => { + expect(target("file:///etc/passwd")).toBe(false); + expect(target("javascript:alert(1)")).toBe(false); + }); +}); diff --git a/web/src/lib/server/camotarget.ts b/web/src/lib/server/camotarget.ts new file mode 100644 index 000000000..bdab9d647 --- /dev/null +++ b/web/src/lib/server/camotarget.ts @@ -0,0 +1,15 @@ +// markup renders client-side, so no renderer can hold the camo secret and +// the web route signs on demand, a public oracle by construction. this +// bounds what that oracle can aim the worker at. camo/src/index.js mirrors +// it, the two deploy independently +const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"]; +const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/; + +export const signableTarget = (url: URL): boolean => { + const host = url.hostname.toLowerCase(); + // ipv6 literals keep their colons in hostname + if (host === "" || host.includes(":") || IPV4.test(host)) return false; + // single-label names are compose/k8s service names, not public hosts + if (!host.includes(".")) return false; + return !BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix)); +}; diff --git a/web/src/routes/camo/[hex]/+server.ts b/web/src/routes/camo/[hex]/+server.ts index 54f0a857f..f02e741bd 100644 --- a/web/src/routes/camo/[hex]/+server.ts +++ b/web/src/routes/camo/[hex]/+server.ts @@ -1,5 +1,6 @@ import { createHmac } from "node:crypto"; import { error } from "@sveltejs/kit"; +import { signableTarget } from "$lib/server/camotarget"; import { getConfig } from "$lib/server/config"; import type { RequestHandler } from "./$types"; @@ -22,6 +23,8 @@ export const GET: RequestHandler = ({ params }) => { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { error(400, "Not a camo url"); } + // the signer is publicly reachable, so it must not aim the worker at internal hosts + if (!signableTarget(parsed)) error(400, "Not a camo url"); // redirecting unsigned would make this an open redirect, so no secret means // no images at all