~james/core

core/camo/src/index.js -rw-r--r-- 4.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 17 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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;
  },
};