Skip to content

Commit e910e01

Browse files
fix(workers): stop edge proxies from following off-origin redirects (#1576)
* fix(workers): stop edge proxies from following off-origin redirects The viewer worker now follows redirects only while they stay under https://geolibre.app/demo, and the tiles worker fetches allowlisted upstreams with the same host-bound redirect policy so neither path can be turned into an open proxy. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(workers): address review on redirects, paths, and S3 scope Pass through 304 responses, reject encoded-slash traversal, scope S3 redirects to known dataset prefixes, harden Protomaps probes, and expand redirect/cookie coverage. * fix(workers): reject alternate ports and cover tiles 304 Block non-default ports on viewer redirect allowlist and lock tiles conditional-revalidation passthrough with an ETag regression test. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent bd2883e commit e910e01

5 files changed

Lines changed: 446 additions & 34 deletions

File tree

tests/edge-proxy-redirect.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import {
4+
MAX_REDIRECT_HOPS,
5+
isAllowedUpstreamUrl,
6+
proxyViewerRequest,
7+
sanitizeViewerPath,
8+
} from "../workers/viewer/src/proxy";
9+
import {
10+
TILES_MAX_REDIRECT_HOPS,
11+
fetchAllowlistedUpstream,
12+
isAllowedTilesUpstreamUrl,
13+
} from "../workers/tiles/src/allowlisted-fetch";
14+
15+
describe("viewer proxy path sanitization", () => {
16+
it("accepts normal asset paths and rejects traversal", () => {
17+
assert.equal(sanitizeViewerPath("/assets/index.js"), "/assets/index.js");
18+
assert.equal(sanitizeViewerPath("/"), "/");
19+
assert.equal(sanitizeViewerPath("/../secret"), null);
20+
assert.equal(sanitizeViewerPath("/foo/../../etc/passwd"), null);
21+
assert.equal(sanitizeViewerPath("/foo%2e%2e/bar"), null);
22+
assert.equal(sanitizeViewerPath("/foo%2f..%2fsecret"), null);
23+
assert.equal(sanitizeViewerPath("/foo%2F..%2Fsecret"), null);
24+
});
25+
});
26+
27+
describe("viewer upstream allowlist", () => {
28+
it("keeps fetches under https://geolibre.app/demo", () => {
29+
assert.equal(isAllowedUpstreamUrl("https://geolibre.app/demo"), true);
30+
assert.equal(isAllowedUpstreamUrl("https://geolibre.app/demo/"), true);
31+
assert.equal(isAllowedUpstreamUrl("https://geolibre.app/demo/assets/a.js"), true);
32+
assert.equal(isAllowedUpstreamUrl("https://geolibre.app/"), false);
33+
assert.equal(isAllowedUpstreamUrl("https://evil.example/demo"), false);
34+
assert.equal(isAllowedUpstreamUrl("http://geolibre.app/demo"), false);
35+
assert.equal(isAllowedUpstreamUrl("https://geolibre.app:8443/demo/assets/a.js"), false);
36+
});
37+
});
38+
39+
describe("viewer redirect policy", () => {
40+
it("follows an in-prefix redirect, strips cookies, and refuses cross-origin", async () => {
41+
const calls: string[] = [];
42+
const fetchImpl: typeof fetch = async (input) => {
43+
const url = String(input);
44+
calls.push(url);
45+
if (url.endsWith("/demo/old")) {
46+
return new Response(null, {
47+
status: 302,
48+
headers: { location: "https://geolibre.app/demo/new" },
49+
});
50+
}
51+
if (url.endsWith("/demo/new")) {
52+
return new Response("ok", {
53+
status: 200,
54+
headers: {
55+
"set-cookie": "session=evil",
56+
"set-cookie2": "also=evil",
57+
"content-type": "text/plain",
58+
},
59+
});
60+
}
61+
return new Response("unexpected", { status: 500 });
62+
};
63+
64+
const ok = await proxyViewerRequest(new Request("https://web.geolibre.app/old"), fetchImpl);
65+
assert.equal(ok.status, 200);
66+
assert.equal(await ok.text(), "ok");
67+
assert.equal(ok.headers.get("set-cookie"), null);
68+
assert.equal(ok.headers.get("set-cookie2"), null);
69+
assert.deepEqual(calls, ["https://geolibre.app/demo/old", "https://geolibre.app/demo/new"]);
70+
71+
const evilFetch: typeof fetch = async () =>
72+
new Response(null, {
73+
status: 302,
74+
headers: { location: "https://evil.example/steal" },
75+
});
76+
const blocked = await proxyViewerRequest(new Request("https://web.geolibre.app/"), evilFetch);
77+
assert.equal(blocked.status, 502);
78+
});
79+
80+
it("passes through 304 Not Modified instead of treating it as a broken redirect", async () => {
81+
const fetchImpl: typeof fetch = async () =>
82+
new Response(null, {
83+
status: 304,
84+
headers: { etag: '"abc"' },
85+
});
86+
const response = await proxyViewerRequest(
87+
new Request("https://web.geolibre.app/assets/app.js", {
88+
headers: { "if-none-match": '"abc"' },
89+
}),
90+
fetchImpl,
91+
);
92+
assert.equal(response.status, 304);
93+
assert.equal(response.headers.get("etag"), '"abc"');
94+
});
95+
96+
it("rejects non-GET methods and caps redirect hops", async () => {
97+
const method = await proxyViewerRequest(
98+
new Request("https://web.geolibre.app/", { method: "POST" }),
99+
);
100+
assert.equal(method.status, 405);
101+
102+
let hops = 0;
103+
const looping: typeof fetch = async (input) => {
104+
hops += 1;
105+
const url = String(input);
106+
return new Response(null, {
107+
status: 302,
108+
headers: { location: `${url}?n=${hops}` },
109+
});
110+
};
111+
const capped = await proxyViewerRequest(new Request("https://web.geolibre.app/loop"), looping);
112+
assert.equal(capped.status, 502);
113+
assert.equal(hops, MAX_REDIRECT_HOPS + 1);
114+
});
115+
});
116+
117+
describe("tiles allowlisted fetch", () => {
118+
it("refuses off-host and off-prefix S3 redirects", async () => {
119+
assert.equal(isAllowedTilesUpstreamUrl("https://api.openaerialmap.org/meta"), true);
120+
assert.equal(isAllowedTilesUpstreamUrl("https://evil.example/meta"), false);
121+
assert.equal(
122+
isAllowedTilesUpstreamUrl(
123+
"https://s3-eu-west-1.amazonaws.com/whereonmars.cartodb.net/mola-color/0/0/0.png",
124+
),
125+
true,
126+
);
127+
assert.equal(
128+
isAllowedTilesUpstreamUrl("https://s3-eu-west-1.amazonaws.com/other-bucket/secret"),
129+
false,
130+
);
131+
132+
const fetchImpl: typeof fetch = async () =>
133+
new Response(null, {
134+
status: 302,
135+
headers: { location: "https://evil.example/payload" },
136+
});
137+
138+
await assert.rejects(
139+
() => fetchAllowlistedUpstream("https://api.openaerialmap.org/meta", {}, fetchImpl),
140+
/non-allowlisted/,
141+
);
142+
});
143+
144+
it("follows a same-host HTTPS redirect and caps hops", async () => {
145+
const fetchImpl: typeof fetch = async (input) => {
146+
const url = String(input);
147+
if (url.endsWith("/meta")) {
148+
return new Response(null, {
149+
status: 301,
150+
headers: { location: "https://api.openaerialmap.org/meta/" },
151+
});
152+
}
153+
return new Response('{"ok":true}', {
154+
status: 200,
155+
headers: { "content-type": "application/json" },
156+
});
157+
};
158+
const response = await fetchAllowlistedUpstream(
159+
"https://api.openaerialmap.org/meta",
160+
{},
161+
fetchImpl,
162+
);
163+
assert.equal(response.status, 200);
164+
assert.equal(await response.text(), '{"ok":true}');
165+
166+
let hops = 0;
167+
const looping: typeof fetch = async (input) => {
168+
hops += 1;
169+
const url = String(input);
170+
return new Response(null, {
171+
status: 302,
172+
headers: { location: `${url}?n=${hops}` },
173+
});
174+
};
175+
await assert.rejects(
176+
() => fetchAllowlistedUpstream("https://api.openaerialmap.org/meta", {}, looping),
177+
/Too many upstream redirects/,
178+
);
179+
assert.equal(hops, TILES_MAX_REDIRECT_HOPS + 1);
180+
});
181+
182+
it("passes through 304 Not Modified with its ETag", async () => {
183+
const fetchImpl: typeof fetch = async () =>
184+
new Response(null, {
185+
status: 304,
186+
headers: { etag: '"tile-abc"' },
187+
});
188+
const response = await fetchAllowlistedUpstream(
189+
"https://api.openaerialmap.org/meta",
190+
{ headers: { "if-none-match": '"tile-abc"' } },
191+
fetchImpl,
192+
);
193+
assert.equal(response.status, 304);
194+
assert.equal(response.headers.get("etag"), '"tile-abc"');
195+
});
196+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Allowlisted upstream URL prefixes the tiles worker may fetch. Named proxies
3+
* (OPM mosaics, USGS WMS, OAM meta, Source Cooperative, Protomaps) are never
4+
* an open proxy — but a 302 from an allowlisted URL to an arbitrary Location
5+
* would reintroduce that risk if `fetch` followed redirects automatically.
6+
*
7+
* S3 entries are scoped to the known OPM dataset path prefixes (not the whole
8+
* shared `s3*.amazonaws.com` host) so a redirect cannot jump to another bucket.
9+
*/
10+
export const TILES_ALLOWED_URL_PREFIXES = [
11+
"https://s3-eu-west-1.amazonaws.com/whereonmars.cartodb.net/",
12+
"https://s3.us-east-2.amazonaws.com/opmmarstiles/",
13+
"https://s3.amazonaws.com/opmbuilder/",
14+
"https://api.openaerialmap.org/",
15+
"https://source.coop/",
16+
"https://build.protomaps.com/",
17+
"https://planetarymaps.usgs.gov/",
18+
] as const;
19+
20+
/** @deprecated Prefer {@link TILES_ALLOWED_URL_PREFIXES}; kept for tests/docs. */
21+
export const TILES_ALLOWED_UPSTREAM_HOSTS = new Set(
22+
TILES_ALLOWED_URL_PREFIXES.map((prefix) => new URL(prefix).hostname),
23+
);
24+
25+
export const TILES_MAX_REDIRECT_HOPS = 5;
26+
27+
/** HTTP statuses that carry a Location and should be followed manually. */
28+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
29+
30+
/** Cloudflare outgoing fetch options (`cf` cache hints, etc.). */
31+
export type TilesFetchInit = RequestInit & {
32+
cf?: RequestInitCfProperties;
33+
};
34+
35+
type FetchLike = (input: RequestInfo | URL, init?: TilesFetchInit) => Promise<Response>;
36+
37+
/**
38+
* Whether a resolved upstream URL is HTTPS and under an allowlisted
39+
* host+path prefix (not merely an allowlisted hostname).
40+
*/
41+
export function isAllowedTilesUpstreamUrl(url: string): boolean {
42+
try {
43+
const parsed = new URL(url);
44+
if (parsed.protocol !== "https:") return false;
45+
const candidate = `${parsed.origin}${parsed.pathname}`;
46+
return TILES_ALLOWED_URL_PREFIXES.some((prefix) => candidate.startsWith(prefix));
47+
} catch {
48+
return false;
49+
}
50+
}
51+
52+
/**
53+
* Fetch an allowlisted upstream URL, following redirects only while they stay
54+
* under an allowlisted HTTPS prefix. Cross-prefix Locations are refused so a
55+
* compromised or misconfigured origin cannot turn the worker into an open proxy.
56+
*/
57+
export async function fetchAllowlistedUpstream(
58+
url: string,
59+
init: TilesFetchInit = {},
60+
fetchImpl: FetchLike = fetch,
61+
): Promise<Response> {
62+
if (!isAllowedTilesUpstreamUrl(url)) {
63+
throw new Error(`Refused fetch to non-allowlisted upstream: ${url}`);
64+
}
65+
66+
let target = url;
67+
for (let hop = 0; hop <= TILES_MAX_REDIRECT_HOPS; hop++) {
68+
const response = await fetchImpl(target, { ...init, redirect: "manual" });
69+
// Pass through non-redirect responses, including 304 Not Modified.
70+
if (!REDIRECT_STATUSES.has(response.status)) {
71+
return response;
72+
}
73+
const location = response.headers.get("location");
74+
if (!location) {
75+
return response;
76+
}
77+
const next = new URL(location, target).toString();
78+
if (!isAllowedTilesUpstreamUrl(next)) {
79+
throw new Error(`Refused redirect to non-allowlisted upstream: ${next}`);
80+
}
81+
target = next;
82+
}
83+
throw new Error("Too many upstream redirects");
84+
}

workers/tiles/src/index.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
// forwards them unchanged. The reprojected WMS tiles are standard XYZ.
3636

3737
import * as UPNG from "upng-js";
38+
import { fetchAllowlistedUpstream } from "./allowlisted-fetch";
3839
import { remapRowsToMercator, tileGeoBounds, wmsBboxFor } from "./reproject";
3940

4041
/** Allowlisted OpenPlanetaryMap tile datasets → their upstream base URL. */
@@ -275,10 +276,19 @@ async function resolveLatestBuildDate(): Promise<string> {
275276
// fetches next — so a later real range read could be served this 1-byte
276277
// body instead of its bytes. The resolved date is memoised in `latestCache`
277278
// already, so no edge cache is needed here.
278-
const probe = await fetch(`${PMTILES_UPSTREAM}/${ymd}.pmtiles`, {
279-
headers: { range: "bytes=0-0" },
280-
});
281-
if (probe.status === 206) {
279+
const probe = await (async () => {
280+
try {
281+
return await fetchAllowlistedUpstream(`${PMTILES_UPSTREAM}/${ymd}.pmtiles`, {
282+
headers: { range: "bytes=0-0" },
283+
});
284+
} catch (err) {
285+
// A single day's probe must not abort the lookback — treat redirect/
286+
// allowlist failures as a miss and try the previous day.
287+
console.warn(`Protomaps build probe failed for ${ymd}: ${String(err)}`);
288+
return null;
289+
}
290+
})();
291+
if (probe?.status === 206) {
282292
latestCache = { date: ymd, at: now };
283293
return ymd;
284294
}
@@ -375,7 +385,7 @@ async function handleSourceCoop(request: Request, pathname: string): Promise<Res
375385
}
376386
let originResponse: Response;
377387
try {
378-
originResponse = await fetch(upstream, {
388+
originResponse = await fetchAllowlistedUpstream(upstream, {
379389
// cacheEverything is required for Cloudflare to edge-cache a URL with no
380390
// static file extension (cacheTtl alone does not).
381391
cf: { cacheEverything: true, cacheTtl: 300 },
@@ -454,7 +464,7 @@ async function handlePmtilesRange(request: Request, name: string): Promise<Respo
454464
// effect on Enterprise plans (silently ignored otherwise), so we don't rely
455465
// on it. Without cacheEverything, Cloudflare doesn't edge-cache the 206 at
456466
// all; the upstream still serves range requests directly.
457-
originResponse = await fetch(`${PMTILES_UPSTREAM}/${target}`, {
467+
originResponse = await fetchAllowlistedUpstream(`${PMTILES_UPSTREAM}/${target}`, {
458468
headers: { range },
459469
});
460470
} catch {
@@ -552,7 +562,7 @@ export default {
552562
}
553563
let originResponse: Response;
554564
try {
555-
originResponse = await fetch(upstream.toString(), {
565+
originResponse = await fetchAllowlistedUpstream(upstream.toString(), {
556566
headers: { accept: "application/json" },
557567
// cacheEverything is required for Cloudflare to edge-cache a URL with
558568
// no static file extension (cacheTtl alone does not).
@@ -662,7 +672,7 @@ export default {
662672
const upstream = `${base}/${z}/${x}/${y}.png`;
663673
let originResponse: Response;
664674
try {
665-
originResponse = await fetch(upstream, {
675+
originResponse = await fetchAllowlistedUpstream(upstream, {
666676
cf: { cacheEverything: true, cacheTtl: 86400 },
667677
});
668678
} catch {
@@ -740,7 +750,9 @@ async function handleWmsTile(
740750

741751
let origin: Response;
742752
try {
743-
origin = await fetch(wmsUrl, { cf: { cacheEverything: true, cacheTtl: 86400 } });
753+
origin = await fetchAllowlistedUpstream(wmsUrl, {
754+
cf: { cacheEverything: true, cacheTtl: 86400 },
755+
});
744756
} catch {
745757
return new Response("Bad Gateway", { status: 502, headers: CORS_HEADERS });
746758
}

0 commit comments

Comments
 (0)