305 lines
9.1 KiB
TypeScript
305 lines
9.1 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { request as httpRequest, type IncomingMessage } from "node:http";
|
|
import type { Socket } from "node:net";
|
|
import {
|
|
createServer as createTLSServer,
|
|
request as httpsRequest,
|
|
type Server as TLSServer,
|
|
} from "node:https";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { Page, Route } from "@playwright/test";
|
|
|
|
type OriginAlias = {
|
|
origin: string;
|
|
host: string;
|
|
};
|
|
|
|
type ProxyResponse = {
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
body: Buffer;
|
|
};
|
|
|
|
type RuntimeRequestWaiter = {
|
|
runtimePath: string;
|
|
refererOrigin: string;
|
|
resolve: (status: number) => void;
|
|
};
|
|
|
|
export type CanvasOriginFixture = {
|
|
aliases: {
|
|
primary: OriginAlias;
|
|
secondary: OriginAlias;
|
|
foreign: OriginAlias;
|
|
nestedForeign: OriginAlias;
|
|
};
|
|
install: (page: Page) => Promise<void>;
|
|
waitForRuntimeRequest: (runtimePath: string, refererOrigin: string) => Promise<number>;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
const virtualPagePath = "/__kandev_canvas_origin_test__";
|
|
|
|
export async function startCanvasOriginFixture(backendURL: string): Promise<CanvasOriginFixture> {
|
|
const certDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-canvas-origin-cert-"));
|
|
const keyPath = path.join(certDirectory, "key.pem");
|
|
const certPath = path.join(certDirectory, "cert.pem");
|
|
const configPath = path.join(certDirectory, "openssl.cnf");
|
|
fs.writeFileSync(
|
|
configPath,
|
|
[
|
|
"[req]",
|
|
"distinguished_name = req_distinguished_name",
|
|
"x509_extensions = v3_req",
|
|
"prompt = no",
|
|
"[req_distinguished_name]",
|
|
"CN = canvas-a.example.test",
|
|
"[v3_req]",
|
|
"subjectAltName = @alt_names",
|
|
"[alt_names]",
|
|
"DNS.1 = canvas-a.example.test",
|
|
"DNS.2 = canvas-b.example.test",
|
|
"DNS.3 = canvas-foreign.example.test",
|
|
"DNS.4 = canvas-nested.example.test",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
execFileSync(
|
|
"openssl",
|
|
[
|
|
"req",
|
|
"-x509",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-nodes",
|
|
"-keyout",
|
|
keyPath,
|
|
"-out",
|
|
certPath,
|
|
"-config",
|
|
configPath,
|
|
"-extensions",
|
|
"v3_req",
|
|
"-days",
|
|
"1",
|
|
],
|
|
{ stdio: "ignore" },
|
|
);
|
|
|
|
const backend = new URL(backendURL);
|
|
const server = createTLSServer(
|
|
{
|
|
key: fs.readFileSync(keyPath),
|
|
cert: fs.readFileSync(certPath),
|
|
},
|
|
(incoming, outgoing) => {
|
|
const upstream = httpRequest(
|
|
{
|
|
hostname: backend.hostname,
|
|
port: Number(backend.port),
|
|
path: incoming.url,
|
|
method: incoming.method,
|
|
headers: {
|
|
...incoming.headers,
|
|
host: backend.host,
|
|
},
|
|
},
|
|
(response) => {
|
|
outgoing.writeHead(response.statusCode ?? 502, response.headers);
|
|
response.pipe(outgoing);
|
|
},
|
|
);
|
|
upstream.on("error", (_error) => {
|
|
if (!outgoing.headersSent) outgoing.writeHead(502);
|
|
outgoing.end();
|
|
});
|
|
incoming.pipe(upstream);
|
|
},
|
|
);
|
|
const port = await listen(server);
|
|
const sockets = new Set<Socket>();
|
|
server.on("connection", (socket) => {
|
|
sockets.add(socket);
|
|
socket.once("close", () => sockets.delete(socket));
|
|
});
|
|
const runtimeRequestWaiters: RuntimeRequestWaiter[] = [];
|
|
const aliases = {
|
|
primary: originAlias("canvas-a.example.test", port),
|
|
secondary: originAlias("canvas-b.example.test", port),
|
|
foreign: originAlias("canvas-foreign.example.test", port),
|
|
nestedForeign: originAlias("canvas-nested.example.test", port),
|
|
};
|
|
|
|
return {
|
|
aliases,
|
|
install: async (page) => {
|
|
const origins = Object.values(aliases).map((alias) => alias.origin);
|
|
await page.route(
|
|
/https:\/\/canvas-(?:a|b|foreign|nested)\.example\.test:\d+\/.*/,
|
|
async (route) => {
|
|
const requestURL = new URL(route.request().url());
|
|
const requestReferer = route.request().headers().referer ?? "";
|
|
let status = 0;
|
|
try {
|
|
status = await handleCanvasOriginRoute(route, origins, port);
|
|
} finally {
|
|
const waiterIndex = runtimeRequestWaiters.findIndex(
|
|
(waiter) =>
|
|
waiter.runtimePath === requestURL.pathname &&
|
|
requestReferer.startsWith(waiter.refererOrigin),
|
|
);
|
|
if (waiterIndex >= 0) {
|
|
runtimeRequestWaiters.splice(waiterIndex, 1)[0]?.resolve(status);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
},
|
|
waitForRuntimeRequest: (runtimePath, refererOrigin) =>
|
|
new Promise<number>((resolve) => {
|
|
runtimeRequestWaiters.push({ runtimePath, refererOrigin, resolve });
|
|
}),
|
|
close: async () => {
|
|
await closeServer(server, sockets);
|
|
fs.rmSync(certDirectory, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
function originAlias(hostname: string, port: number): OriginAlias {
|
|
return { host: `${hostname}:${port}`, origin: `https://${hostname}:${port}` };
|
|
}
|
|
|
|
async function handleCanvasOriginRoute(
|
|
route: Route,
|
|
origins: string[],
|
|
port: number,
|
|
): Promise<number> {
|
|
const requestURL = new URL(route.request().url());
|
|
if (requestURL.pathname === virtualPagePath) {
|
|
const source = requestURL.searchParams.get("src");
|
|
if (!source || !origins.some((origin) => source.startsWith(origin))) {
|
|
await route.fulfill({ status: 400, body: "missing test frame source" });
|
|
return 400;
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/html",
|
|
body: canvasWrapperHTML(source),
|
|
});
|
|
return 200;
|
|
}
|
|
// The origin test verifies document policy, not long-lived event delivery.
|
|
// Complete the stream locally so navigation between aliases does not reset
|
|
// an in-flight proxy request while the browser tears down the old iframe.
|
|
if (requestURL.pathname.endsWith("/_kandev/v1/events")) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: {
|
|
"Cache-Control": "no-cache",
|
|
"Content-Type": "text/event-stream",
|
|
},
|
|
body: ": origin fixture stream\n\n",
|
|
});
|
|
return 200;
|
|
}
|
|
|
|
const response = await requestThroughTLSProxy({
|
|
port,
|
|
host: requestURL.host,
|
|
path: `${requestURL.pathname}${requestURL.search}`,
|
|
method: route.request().method(),
|
|
headers: route.request().headers(),
|
|
body: route.request().postDataBuffer() ?? undefined,
|
|
});
|
|
await route.fulfill({
|
|
status: response.status,
|
|
headers: response.headers,
|
|
body: response.body,
|
|
});
|
|
return response.status;
|
|
}
|
|
|
|
function canvasWrapperHTML(source: string): string {
|
|
const escapedSource = source
|
|
.replaceAll("&", "&")
|
|
.replaceAll('"', """)
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
return [
|
|
"<!doctype html>",
|
|
'<html lang="en"><head><meta charset="utf-8"><title>Canvas origin test</title></head>',
|
|
'<body style="margin:0"><iframe id="runtime" title="Canvas runtime" style="display:block;width:100vw;height:100vh;border:0" src="',
|
|
escapedSource,
|
|
'"></iframe></body></html>',
|
|
].join("");
|
|
}
|
|
|
|
function requestThroughTLSProxy(options: {
|
|
port: number;
|
|
host: string;
|
|
path: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
body?: Buffer;
|
|
}): Promise<ProxyResponse> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = httpsRequest(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port: options.port,
|
|
path: options.path,
|
|
method: options.method,
|
|
rejectUnauthorized: false,
|
|
headers: { ...options.headers, host: options.host },
|
|
},
|
|
(response: IncomingMessage) => {
|
|
const chunks: Buffer[] = [];
|
|
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
response.on("end", () => {
|
|
const headers: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(response.headers)) {
|
|
if (
|
|
value !== undefined &&
|
|
!["connection", "keep-alive", "transfer-encoding"].includes(key)
|
|
) {
|
|
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
}
|
|
}
|
|
resolve({ status: response.statusCode ?? 502, headers, body: Buffer.concat(chunks) });
|
|
});
|
|
},
|
|
);
|
|
request.on("error", (error) => {
|
|
reject(new Error(`canvas origin proxy ${options.host}${options.path}: ${error.message}`));
|
|
});
|
|
if (options.body) request.write(options.body);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
function listen(server: TLSServer): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("canvas origin fixture did not receive a port"));
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function closeServer(server: TLSServer | undefined, sockets: Set<Socket>): Promise<void> {
|
|
if (!server) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
for (const socket of sockets) socket.destroy();
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|