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
33 changes: 23 additions & 10 deletions src/lib/runtime-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ export const generateShimScript = (
return false;
}

// Prepend the configured proxyBase so emitted URLs are ABSOLUTE, mirroring the
// server-side url-rewriter's buildProxyUrl. When pages are embedded cross-origin
// (e.g. inside beta.911realtime.org or reached via the box DNS dev.keepinghistory.org),
// a root-relative /web/... path resolves against the embedding/browsing host
// instead of the canonical proxy host. Absolute output pins every runtime-built
// URL to proxyBase. When proxyBase is unset, output stays root-relative (the
// backward-compatible direct-proxy default).
function proxify(path) {
return _base ? _base + path : path;
}

function rewrite(url, isLink) {
if (!url || typeof url !== 'string') return url;
var trimmed = url.trim();
Expand All @@ -62,16 +73,18 @@ export const generateShimScript = (
if (SKIP_RE.test(trimmed)) return url;
var lock = _lock && !!isLink;
// Wayback-wrapped (absolute or protocol-relative): unwrap to the embedded
// (ts, url) and emit a clean path-form URL the proxy understands.
// (ts, url) and emit a clean proxy URL the proxy understands.
var wm = trimmed.match(WAYBACK_ABS_RE);
if (wm) return lock ? '/web/' + wm[2] : '/web/' + wm[1] + '/' + wm[2];
// Absolute URL pointing at the proxy's own origin (emitted by the server-side
// url-rewriter when proxyBase is set): strip the base so the result is a
// root-relative /web/... path. Without this, the URL falls through to the
// new URL() branch below and gets double-wrapped as /web/<ts>im_/<proxyBase>/web/...
if (_base && trimmed.indexOf(_base + '/web/') === 0) return trimmed.slice(_base.length);
// Already proxied (root-relative path): idempotent pass-through.
if (trimmed.indexOf('/web/') === 0) return url;
if (wm) return proxify(lock ? '/web/' + wm[2] : '/web/' + wm[1] + '/' + wm[2]);
// Already an absolute proxy URL pointing at proxyBase (emitted by the
// server-side url-rewriter, or by a previous pass of this shim): leave it
// untouched. It is already in canonical absolute form — stripping it back to
// a root-relative path would re-introduce the cross-origin leak, and
// re-wrapping it would double-wrap as /web/<ts>im_/<proxyBase>/web/...
if (_base && trimmed.indexOf(_base + '/web/') === 0) return url;
// Root-relative proxy path: idempotent, but upgrade to absolute when
// proxyBase is set so it resolves against the proxy host, not the embedder.
if (trimmed.indexOf('/web/') === 0) return _base ? _base + trimmed : url;
// Resolve relative URLs against the original page URL.
var absolute;
try {
Expand All @@ -81,7 +94,7 @@ export const generateShimScript = (
}
// Only rewrite http/https.
if (absolute.indexOf('http://') !== 0 && absolute.indexOf('https://') !== 0) return url;
return lock ? '/web/' + absolute : '/web/' + _ts + 'im_/' + absolute;
return proxify(lock ? '/web/' + absolute : '/web/' + _ts + 'im_/' + absolute);
}

// --- window.fetch ---
Expand Down
60 changes: 40 additions & 20 deletions tests/lib/runtime-shim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,19 +824,19 @@ describe("rewrite() — path-form /web/<ts>/<url> input is pass-through (no doub
});
});

// ─── proxyBase: absolute proxy URL idempotency ───────────────────────────────
// ─── proxyBase: runtime-built URLs are emitted ABSOLUTE ───────────────────────
//
// When the server-side url-rewriter has proxyBase set (e.g. https://dev.keepinghistory.org),
// it emits absolute hrefs like https://dev.keepinghistory.org/web/<ts>/<url>.
// Without this fix, the shim treats those as external URLs and double-wraps them:
// /web/<ts>im_/https://dev.keepinghistory.org/web/<ts>/<url>
// With the fix, the shim strips the proxyBase prefix to get the root-relative
// path form, which is the already-proxied idempotent guard.
// The server-side url-rewriter emits absolute hrefs (https://proxy.example.com/
// web/<ts>/<url>) when proxyBase is set, so that pages embedded cross-origin (or
// reached via a secondary box DNS like dev.keepinghistory.org) resolve correctly.
// The shim MUST match: every URL it builds at runtime is also prefixed with
// proxyBase. A root-relative /web/... path would otherwise resolve against the
// embedding/browsing host, leaking the wrong hostname.

const PROXY_BASE = "https://proxy.example.com";

describe("rewrite() — proxyBase absolute URL is not double-wrapped", () => {
it("strips proxyBase prefix from an absolute proxy URL via window.fetch", async () => {
describe("rewrite() — proxyBase: runtime URLs are emitted absolute", () => {
it("prefixes proxyBase onto a relative URL via window.fetch (the core fix)", async () => {
const captured: string[] = [];

const realFetch = window.fetch;
Expand All @@ -846,17 +846,39 @@ describe("rewrite() — proxyBase absolute URL is not double-wrapped", () => {
};
installShim(TS, ORIG_URL, PROXY_BASE);

await window.fetch(`${PROXY_BASE}/web/${TS}/http://www.apple.com/`);
await window.fetch("/api/data.json");

installShim(); // restore default (no proxyBase)
window.fetch = realFetch;

// Absolute, pinned to proxyBase — NOT a bare /web/... that would resolve
// against the embedding host.
expect(captured[0]).toBe(`${PROXY_BASE}/web/${TS}im_/http://www.example.com/api/data.json`);
});

it("keeps an already-absolute proxy URL untouched (no double-wrap, no demotion)", async () => {
const captured: string[] = [];

const realFetch = window.fetch;
window.fetch = (input: RequestInfo | URL): Promise<Response> => {
captured.push(typeof input === "string" ? input : (input as Request).url);
return Promise.resolve({ ok: true } as Response);
};
installShim(TS, ORIG_URL, PROXY_BASE);

const already = `${PROXY_BASE}/web/${TS}/http://www.apple.com/`;
await window.fetch(already);

installShim(); // restore default (no proxyBase)
window.fetch = realFetch;

// Must be stripped to path form — NOT double-wrapped as /web/<ts>im_/<proxyBase>/web/...
expect(captured[0]).toBe(`/web/${TS}/http://www.apple.com/`);
expect(captured[0]).not.toContain(PROXY_BASE);
// Idempotent: already canonical absolute form — left exactly as-is. Must
// NOT be double-wrapped (/web/<ts>im_/<proxyBase>/web/...) nor demoted to a
// root-relative path (which would re-introduce the cross-origin leak).
expect(captured[0]).toBe(already);
});

it("strips proxyBase prefix with a timestamp modifier in the inner path", async () => {
it("upgrades a root-relative proxy path to absolute when proxyBase is set", async () => {
const captured: string[] = [];

const realFetch = window.fetch;
Expand All @@ -866,16 +888,15 @@ describe("rewrite() — proxyBase absolute URL is not double-wrapped", () => {
};
installShim(TS, ORIG_URL, PROXY_BASE);

await window.fetch(`${PROXY_BASE}/web/${INNER_TS}/http://cdn.example.com/img.gif`);
await window.fetch(`/web/${INNER_TS}/http://cdn.example.com/img.gif`);

installShim();
window.fetch = realFetch;

expect(captured[0]).toBe(`/web/${INNER_TS}/http://cdn.example.com/img.gif`);
expect(captured[0]).not.toContain(PROXY_BASE);
expect(captured[0]).toBe(`${PROXY_BASE}/web/${INNER_TS}/http://cdn.example.com/img.gif`);
});

it("does not affect URLs on a different host when proxyBase is set", async () => {
it("wraps an external-host URL absolute under proxyBase", async () => {
const captured: string[] = [];

const realFetch = window.fetch;
Expand All @@ -890,7 +911,6 @@ describe("rewrite() — proxyBase absolute URL is not double-wrapped", () => {
installShim();
window.fetch = realFetch;

// External URL should still be wrapped, not stripped
expect(captured[0]).toBe(`/web/${TS}im_/http://other.com/page.html`);
expect(captured[0]).toBe(`${PROXY_BASE}/web/${TS}im_/http://other.com/page.html`);
});
});
Loading