|
import BareClient, { BareResponseFetch } from "@mercuryworkshop/bare-mux";
|
|
import { ScramjetServiceWorker } from ".";
|
|
import { renderError } from "./error";
|
|
import { FakeServiceWorker } from "./fakesw";
|
|
import { CookieStore } from "../shared/cookie";
|
|
|
|
import { getSiteDirective } from "../shared/security/siteTests";
|
|
import {
|
|
initializeTracker,
|
|
updateTracker,
|
|
cleanTracker,
|
|
getMostRestrictiveSite,
|
|
storeReferrerPolicy,
|
|
getReferrerPolicy,
|
|
} from "../shared/security/forceReferrer";
|
|
|
|
import {
|
|
unrewriteBlob,
|
|
unrewriteUrl,
|
|
type URLMeta,
|
|
} from "../shared/rewriters/url";
|
|
import { rewriteJsWithMap } from "../shared/rewriters/js";
|
|
import { ScramjetHeaders } from "../shared/headers";
|
|
import { config, flagEnabled } from "../shared";
|
|
import { rewriteHeaders } from "../shared/rewriters/headers";
|
|
import { rewriteHtml } from "../shared/rewriters/html";
|
|
import { rewriteCss } from "../shared/rewriters/css";
|
|
import { rewriteWorkers } from "../shared/rewriters/worker";
|
|
|
|
export async function handleFetch(
|
|
this: ScramjetServiceWorker,
|
|
request: Request,
|
|
client: Client | null
|
|
) {
|
|
try {
|
|
const requestUrl = new URL(request.url);
|
|
|
|
if (requestUrl.pathname === this.config.files.wasm) {
|
|
return fetch(this.config.files.wasm).then(async (x) => {
|
|
const buf = await x.arrayBuffer();
|
|
const b64 = btoa(
|
|
new Uint8Array(buf)
|
|
.reduce(
|
|
(data, byte) => (data.push(String.fromCharCode(byte)), data),
|
|
[]
|
|
)
|
|
.join("")
|
|
);
|
|
|
|
let payload = "";
|
|
payload +=
|
|
"if ('document' in self && document.currentScript) { document.currentScript.remove(); }\n";
|
|
payload += `self.WASM = '${b64}';`;
|
|
|
|
return new Response(payload, {
|
|
headers: { "content-type": "text/javascript" },
|
|
});
|
|
});
|
|
}
|
|
|
|
let workerType = "";
|
|
if (requestUrl.searchParams.has("type")) {
|
|
workerType = requestUrl.searchParams.get("type") as string;
|
|
requestUrl.searchParams.delete("type");
|
|
}
|
|
if (requestUrl.searchParams.has("dest")) {
|
|
requestUrl.searchParams.delete("dest");
|
|
}
|
|
const url = new URL(unrewriteUrl(requestUrl));
|
|
|
|
const meta: URLMeta = {
|
|
origin: url,
|
|
base: url,
|
|
};
|
|
if (requestUrl.searchParams.has("topFrame")) {
|
|
meta.topFrameName = requestUrl.searchParams.get("topFrame");
|
|
}
|
|
if (requestUrl.searchParams.has("parentFrame")) {
|
|
meta.parentFrameName = requestUrl.searchParams.get("parentFrame");
|
|
}
|
|
|
|
if (
|
|
requestUrl.pathname.startsWith(`${this.config.prefix}blob:`) ||
|
|
requestUrl.pathname.startsWith(`${this.config.prefix}data:`)
|
|
) {
|
|
let dataUrl = requestUrl.pathname.substring(this.config.prefix.length);
|
|
if (dataUrl.startsWith("blob:")) {
|
|
dataUrl = unrewriteBlob(dataUrl);
|
|
}
|
|
|
|
const response: Partial<BareResponseFetch> = await fetch(dataUrl, {});
|
|
const url = dataUrl.startsWith("blob:") ? dataUrl : "(data url)";
|
|
response.finalURL = url;
|
|
let body: BodyType;
|
|
|
|
if (response.body) {
|
|
body = await rewriteBody(
|
|
response as BareResponseFetch,
|
|
meta,
|
|
request.destination,
|
|
workerType,
|
|
this.cookieStore
|
|
);
|
|
}
|
|
const headers = Object.fromEntries(response.headers.entries());
|
|
|
|
if (crossOriginIsolated) {
|
|
headers["Cross-Origin-Opener-Policy"] = "same-origin";
|
|
headers["Cross-Origin-Embedder-Policy"] = "require-corp";
|
|
}
|
|
|
|
return new Response(body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: headers,
|
|
});
|
|
}
|
|
|
|
const activeWorker: FakeServiceWorker | null = this.serviceWorkers.find(
|
|
(w) => w.origin === url.origin
|
|
);
|
|
|
|
if (
|
|
activeWorker?.connected &&
|
|
requestUrl.searchParams.get("from") !== "swruntime"
|
|
) {
|
|
|
|
const r = await activeWorker.fetch(request);
|
|
if (r) return r;
|
|
}
|
|
if (url.origin === new URL(request.url).origin) {
|
|
throw new Error(
|
|
"attempted to fetch from same origin - this means the site has obtained a reference to the real origin, aborting"
|
|
);
|
|
}
|
|
|
|
const headers = new ScramjetHeaders();
|
|
for (const [key, value] of request.headers.entries()) {
|
|
headers.set(key, value);
|
|
}
|
|
|
|
if (client && new URL(client.url).pathname.startsWith(config.prefix)) {
|
|
|
|
const clientURL = new URL(unrewriteUrl(client.url));
|
|
if (clientURL.toString().includes("youtube.com")) {
|
|
|
|
} else {
|
|
|
|
headers.set("Referer", clientURL.href);
|
|
headers.set("Origin", clientURL.origin);
|
|
}
|
|
}
|
|
|
|
const cookies = this.cookieStore.getCookies(url, false);
|
|
|
|
if (cookies.length) {
|
|
headers.set("Cookie", cookies);
|
|
}
|
|
|
|
|
|
let isTopLevelProxyNavigation = false;
|
|
if (
|
|
request.destination === "iframe" &&
|
|
request.mode === "navigate" &&
|
|
request.referrer &&
|
|
request.referrer !== "no-referrer"
|
|
) {
|
|
|
|
let currentReferrer = request.referrer;
|
|
const allClients = await self.clients.matchAll({ type: "window" });
|
|
|
|
|
|
while (currentReferrer) {
|
|
if (!currentReferrer.includes(config.prefix)) {
|
|
isTopLevelProxyNavigation = true;
|
|
break;
|
|
}
|
|
|
|
|
|
const parentChainClient = allClients.find(
|
|
(c) => c.url === currentReferrer
|
|
);
|
|
|
|
|
|
|
|
const parentPolicyData = await getReferrerPolicy(currentReferrer);
|
|
|
|
if (!parentPolicyData || !parentPolicyData.referrer) {
|
|
|
|
if (
|
|
parentChainClient &&
|
|
currentReferrer.startsWith(location.origin)
|
|
) {
|
|
isTopLevelProxyNavigation = true;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
|
|
if (parentChainClient && parentChainClient.frameType === "nested") {
|
|
|
|
currentReferrer = parentPolicyData.referrer;
|
|
} else {
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isTopLevelProxyNavigation) {
|
|
headers.set("Sec-Fetch-Dest", "document");
|
|
headers.set("Sec-Fetch-Mode", "navigate");
|
|
} else {
|
|
|
|
headers.set("Sec-Fetch-Dest", request.destination || "empty");
|
|
headers.set("Sec-Fetch-Mode", request.mode);
|
|
}
|
|
|
|
let siteDirective = "none";
|
|
if (
|
|
request.referrer &&
|
|
request.referrer !== "" &&
|
|
request.referrer !== "no-referrer"
|
|
) {
|
|
if (request.referrer.includes(config.prefix)) {
|
|
const unrewrittenReferrer = unrewriteUrl(request.referrer);
|
|
if (unrewrittenReferrer) {
|
|
const referrerUrl = new URL(unrewrittenReferrer);
|
|
siteDirective = await getSiteDirective(
|
|
meta,
|
|
referrerUrl,
|
|
this.client
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
await initializeTracker(
|
|
url.toString(),
|
|
request.referrer ? unrewriteUrl(request.referrer) : null,
|
|
siteDirective
|
|
);
|
|
|
|
headers.set(
|
|
"Sec-Fetch-Site",
|
|
await getMostRestrictiveSite(url.toString(), siteDirective)
|
|
);
|
|
|
|
const ev = new ScramjetRequestEvent(
|
|
url,
|
|
headers.headers,
|
|
request.body,
|
|
request.method,
|
|
request.destination,
|
|
client
|
|
);
|
|
this.dispatchEvent(ev);
|
|
|
|
const response: BareResponseFetch =
|
|
ev.response ||
|
|
(await this.client.fetch(ev.url, {
|
|
method: ev.method,
|
|
body: ev.body,
|
|
headers: ev.requestHeaders,
|
|
credentials: "omit",
|
|
mode: request.mode === "cors" ? request.mode : "same-origin",
|
|
cache: request.cache,
|
|
redirect: "manual",
|
|
|
|
duplex: "half",
|
|
}));
|
|
|
|
return await handleResponse(
|
|
url,
|
|
meta,
|
|
workerType,
|
|
request.destination,
|
|
request.mode,
|
|
response,
|
|
this.cookieStore,
|
|
client,
|
|
this.client,
|
|
this,
|
|
request.referrer
|
|
);
|
|
} catch (err) {
|
|
const errorDetails = {
|
|
message: err.message,
|
|
url: request.url,
|
|
destination: request.destination,
|
|
};
|
|
if (err.stack) {
|
|
errorDetails["stack"] = err.stack;
|
|
}
|
|
|
|
console.error("ERROR FROM SERVICE WORKER FETCH: ", errorDetails);
|
|
console.error(err);
|
|
|
|
if (!["document", "iframe"].includes(request.destination))
|
|
return new Response(undefined, { status: 500 });
|
|
|
|
const formattedError = Object.entries(errorDetails)
|
|
.map(
|
|
([key, value]) =>
|
|
`${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`
|
|
)
|
|
.join("\n\n");
|
|
|
|
return renderError(formattedError, unrewriteUrl(request.url));
|
|
}
|
|
}
|
|
|
|
async function handleResponse(
|
|
url: URL,
|
|
meta: URLMeta,
|
|
workertype: string,
|
|
destination: RequestDestination,
|
|
mode: RequestMode,
|
|
response: BareResponseFetch,
|
|
cookieStore: CookieStore,
|
|
client: Client,
|
|
bareClient: BareClient,
|
|
swtarget: ScramjetServiceWorker,
|
|
referrer: string
|
|
): Promise<Response> {
|
|
let responseBody: BodyType;
|
|
const isNavigationRequest =
|
|
mode === "navigate" && ["document", "iframe"].includes(destination);
|
|
const responseHeaders = await rewriteHeaders(
|
|
response.rawHeaders,
|
|
meta,
|
|
bareClient,
|
|
{ get: getReferrerPolicy, set: storeReferrerPolicy }
|
|
);
|
|
|
|
|
|
if (isNavigationRequest && responseHeaders["referrer-policy"] && referrer) {
|
|
await storeReferrerPolicy(
|
|
url.href,
|
|
responseHeaders["referrer-policy"],
|
|
referrer
|
|
);
|
|
}
|
|
|
|
if (
|
|
response.status >= 300 &&
|
|
response.status < 400 &&
|
|
responseHeaders["location"]
|
|
) {
|
|
const redirectUrl = new URL(unrewriteUrl(responseHeaders["location"]));
|
|
|
|
await updateTracker(
|
|
url.toString(),
|
|
redirectUrl.toString(),
|
|
responseHeaders["referrer-policy"]
|
|
);
|
|
|
|
const redirectMeta = {
|
|
origin: redirectUrl,
|
|
base: redirectUrl,
|
|
};
|
|
const newSiteDirective = await getSiteDirective(
|
|
redirectMeta,
|
|
url,
|
|
bareClient
|
|
);
|
|
await getMostRestrictiveSite(redirectUrl.toString(), newSiteDirective);
|
|
}
|
|
|
|
const maybeHeaders = responseHeaders["set-cookie"] || [];
|
|
for (const cookie in maybeHeaders) {
|
|
if (client) {
|
|
const promise = swtarget.dispatch(client, {
|
|
scramjet$type: "cookie",
|
|
cookie,
|
|
url: url.href,
|
|
});
|
|
if (destination !== "document" && destination !== "iframe") {
|
|
await promise;
|
|
}
|
|
}
|
|
}
|
|
|
|
await cookieStore.setCookies(
|
|
maybeHeaders instanceof Array ? maybeHeaders : [maybeHeaders],
|
|
url
|
|
);
|
|
|
|
for (const header in responseHeaders) {
|
|
|
|
if (Array.isArray(responseHeaders[header]))
|
|
responseHeaders[header] = responseHeaders[header][0];
|
|
}
|
|
|
|
if (response.body) {
|
|
responseBody = await rewriteBody(
|
|
response,
|
|
meta,
|
|
destination,
|
|
workertype,
|
|
cookieStore
|
|
);
|
|
}
|
|
|
|
|
|
if (["document", "iframe"].includes(destination)) {
|
|
const header = responseHeaders["content-disposition"];
|
|
|
|
|
|
if (!/\s*?((inline|attachment);\s*?)filename=/i.test(header)) {
|
|
|
|
|
|
const type = /^\s*?attachment/i.test(header) ? "attachment" : "inline";
|
|
|
|
|
|
const [filename] = new URL(response.finalURL).pathname
|
|
.split("/")
|
|
.slice(-1);
|
|
|
|
responseHeaders["content-disposition"] =
|
|
`${type}; filename=${JSON.stringify(filename)}`;
|
|
}
|
|
}
|
|
if (responseHeaders["accept"] === "text/event-stream") {
|
|
responseHeaders["content-type"] = "text/event-stream";
|
|
}
|
|
|
|
|
|
delete responseHeaders["permissions-policy"];
|
|
|
|
if (
|
|
crossOriginIsolated &&
|
|
[
|
|
"document",
|
|
"iframe",
|
|
"worker",
|
|
"sharedworker",
|
|
"style",
|
|
"script",
|
|
].includes(destination)
|
|
) {
|
|
responseHeaders["Cross-Origin-Embedder-Policy"] = "require-corp";
|
|
responseHeaders["Cross-Origin-Opener-Policy"] = "same-origin";
|
|
}
|
|
|
|
const ev = new ScramjetHandleResponseEvent(
|
|
responseBody,
|
|
responseHeaders,
|
|
response.status,
|
|
response.statusText,
|
|
destination,
|
|
url,
|
|
response,
|
|
client
|
|
);
|
|
swtarget.dispatchEvent(ev);
|
|
|
|
|
|
if (!(response.status >= 300 && response.status < 400)) {
|
|
await cleanTracker(url.toString());
|
|
}
|
|
|
|
return new Response(ev.responseBody, {
|
|
headers: ev.responseHeaders as HeadersInit,
|
|
status: ev.status,
|
|
statusText: ev.statusText,
|
|
});
|
|
}
|
|
|
|
async function rewriteBody(
|
|
response: BareResponseFetch,
|
|
meta: URLMeta,
|
|
destination: RequestDestination,
|
|
workertype: string,
|
|
cookieStore: CookieStore
|
|
): Promise<BodyType> {
|
|
switch (destination) {
|
|
case "iframe":
|
|
case "document":
|
|
if (response.headers.get("content-type")?.startsWith("text/html")) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return rewriteHtml(await response.text(), cookieStore, meta, true);
|
|
} else {
|
|
return response.body;
|
|
}
|
|
case "script": {
|
|
let { js, tag, map } = rewriteJsWithMap(
|
|
new Uint8Array(await response.arrayBuffer()),
|
|
response.finalURL,
|
|
meta,
|
|
workertype === "module"
|
|
);
|
|
if (flagEnabled("sourcemaps", meta.base) && map) {
|
|
if (js instanceof Uint8Array) {
|
|
js = new TextDecoder().decode(js);
|
|
}
|
|
const sourcemapfn = `${config.globals.pushsourcemapfn}([${map.join(",")}], "${tag}");`;
|
|
const strictMode = /^\s*(['"])use strict\1;?/;
|
|
if (strictMode.test(js)) {
|
|
js = js.replace(strictMode, `$&\n${sourcemapfn}`);
|
|
} else {
|
|
js = `${sourcemapfn}\n${js}`;
|
|
}
|
|
}
|
|
|
|
return js as unknown as ArrayBuffer;
|
|
}
|
|
case "style":
|
|
return rewriteCss(await response.text(), meta);
|
|
case "sharedworker":
|
|
case "worker":
|
|
return rewriteWorkers(
|
|
new Uint8Array(await response.arrayBuffer()),
|
|
workertype,
|
|
response.finalURL,
|
|
meta
|
|
);
|
|
default:
|
|
return response.body;
|
|
}
|
|
}
|
|
|
|
type BodyType = string | ArrayBuffer | Blob | ReadableStream<any>;
|
|
|
|
export class ScramjetHandleResponseEvent extends Event {
|
|
constructor(
|
|
public responseBody: BodyType,
|
|
public responseHeaders: Record<string, string>,
|
|
public status: number,
|
|
public statusText: string,
|
|
public destination: string,
|
|
public url: URL,
|
|
public rawResponse: BareResponseFetch,
|
|
public client: Client
|
|
) {
|
|
super("handleResponse");
|
|
}
|
|
}
|
|
|
|
export class ScramjetRequestEvent extends Event {
|
|
constructor(
|
|
public url: URL,
|
|
public requestHeaders: Record<string, string>,
|
|
public body: BodyType,
|
|
public method: string,
|
|
public destination: string,
|
|
public client: Client
|
|
) {
|
|
super("request");
|
|
}
|
|
public response?: BareResponseFetch;
|
|
}
|
|
|