import allowedMimeTypes from "./mimetypes.json";
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));
};
const SANDBOX_CSP =
"default-src 'none'; style-src 'unsafe-inline'; sandbox";
class ServedCamoType {
#header;
static fromUpstream(raw) {
const header = raw || "application/octet-stream";
return allowedMimeTypes.includes(header.split(";")[0].trim())
? new ServedCamoType(header)
: null;
}
constructor(header) {
this.#header = header;
}
get header() {
return this.#header;
}
}
class SandboxedResponse extends Response {
constructor(body, init = {}) {
super(body, init);
this.headers.set("X-Content-Type-Options", "nosniff");
this.headers.set("Content-Security-Policy", SANDBOX_CSP);
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/" || url.pathname === "") {
return new Response(
"This is Tangled's Camo service. It proxies images served from knots via Cloudflare.",
);
}
const cache = caches.default;
const pathParts = url.pathname.slice(1).split("/");
if (pathParts.length < 2) {
return new Response("Bad URL", { status: 400 });
}
const [signatureHex, ...hexUrlParts] = pathParts;
const hexUrl = hexUrlParts.join("");
const urlBytes = Uint8Array.from(
hexUrl.match(/.{2}/g).map((b) => parseInt(b, 16)),
);
const targetUrl = new TextDecoder().decode(urlBytes);
// check signature before we lookup cache, if we do the other way
// then any random signature for the same url will be let through after
// a single successful request
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(env.CAMO_SHARED_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes);
const computedSig = Array.from(new Uint8Array(computedSigBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
console.log({
level: "debug",
message: "camo target: " + targetUrl,
computedSignature: computedSig,
providedSignature: signatureHex,
targetUrl: targetUrl,
});
const sigBytes = Uint8Array.from(
signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
);
const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes);
if (!valid) {
return new Response("Invalid signature", { status: 403 });
}
let parsedUrl;
try {
parsedUrl = new URL(targetUrl);
if (!["https:", "http:"].includes(parsedUrl.protocol)) {
return new Response("Only HTTP(S) allowed", { status: 400 });
}
} catch {
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 new SandboxedResponse(response.body, response);
}
// fetch from the parsed URL
const res = await fetch(parsedUrl.toString(), {
headers: { "User-Agent": "Tangled Camo v0.1.0" },
});
const served = ServedCamoType.fromUpstream(res.headers.get("Content-Type"));
if (!served) {
return new Response("Unsupported media type", { status: 415 });
}
// serve and cache it with cf
response = new SandboxedResponse(await res.arrayBuffer(), {
status: res.status,
headers: {
"Content-Type": served.header,
"Cache-Control": "public, max-age=86400, immutable",
},
});
await cache.put(cacheKey, response.clone());
return response;
},
};