Files
kandev/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts
T
2026-09-21 23:20:52 +01:00

367 lines
12 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { request as httpRequest, type ClientRequest, type IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import { createServer as createTLSServer, type Server as TLSServer } from "node:https";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { BrowserContext } from "@playwright/test";
export const CANVAS_PROXY_COOKIE_NAME = "kandev_e2e_access";
type RuntimeRequestKind =
| "runtime-entry"
| "runtime-host"
| "runtime-asset"
| "context"
| "data"
| "write"
| "events";
export type CanvasProxyObservation = {
authenticated: boolean;
host: string;
kind: RuntimeRequestKind;
method: string;
};
export type CanvasAuthenticatedProxy = {
origin: string;
host: string;
cookieName: string;
setAuthentication: (context: BrowserContext) => Promise<void>;
clearAuthentication: (context: BrowserContext) => Promise<void>;
count: (kind: RuntimeRequestKind, authenticated?: boolean) => number;
observations: () => CanvasProxyObservation[];
activeUpstreamRequests: () => number;
injectedRuntimeDocuments: () => number;
close: () => Promise<void>;
};
export type CanvasAuthenticatedProxyOptions = {
/** Ask the proxy to try an HTML transformation when the origin permits it. */
injectRuntimeHtml?: boolean;
/** Simulate a proxy that removes the origin's no-transform directive. */
stripNoTransform?: boolean;
};
const runtimePrefix = "/api/v1/plugins/web-apps/runtime/";
export async function startCanvasAuthenticatedProxy(
backendURL: string,
options: CanvasAuthenticatedProxyOptions = {},
): Promise<CanvasAuthenticatedProxy> {
const certificate = createCertificate();
const backend = new URL(backendURL);
const observations: CanvasProxyObservation[] = [];
const upstreamRequests = new Set<ClientRequest>();
const upstreamResponses = new Set<IncomingMessage>();
let injectedRuntimeDocumentCount = 0;
const server = createTLSServer(
{
key: fs.readFileSync(certificate.keyPath),
cert: fs.readFileSync(certificate.certPath),
},
(incoming, outgoing) => {
const requestURL = new URL(incoming.url ?? "/", "https://127.0.0.1");
const kind = runtimeRequestKind(requestURL.pathname, incoming.method ?? "GET");
const host = incoming.headers.host ?? "";
const authenticated = hasAuthenticationCookie(incoming.headers.cookie);
if (kind) {
observations.push({
authenticated,
host,
kind,
method: incoming.method ?? "GET",
});
}
if (requestURL.pathname.startsWith(runtimePrefix) && !authenticated) {
outgoing.writeHead(401, {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
outgoing.end(JSON.stringify({ error: "proxy_auth_required" }));
return;
}
const publicHost = host || `${certificate.hostname}:${certificate.port}`;
const headers = {
...incoming.headers,
host: publicHost,
"x-forwarded-host": publicHost,
"x-forwarded-proto": "https",
};
let upstreamResponse: IncomingMessage | undefined;
const upstream = httpRequest(
{
hostname: backend.hostname,
port: Number(backend.port),
path: incoming.url,
method: incoming.method,
headers,
},
(response) => {
upstreamResponse = response;
upstreamResponses.add(response);
response.once("close", () => upstreamResponses.delete(response));
const headers = proxyResponseHeaders(response.headers, options.stripNoTransform === true);
const shouldTransform =
options.injectRuntimeHtml === true &&
kind === "runtime-entry" &&
isHtmlContentType(headers["content-type"]) &&
!hasNoTransformDirective(headers["cache-control"]) &&
!headers["content-encoding"];
if (shouldTransform) {
const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
response.once("end", () => {
if (outgoing.destroyed) return;
const original = Buffer.concat(chunks);
const transformed = injectRuntimeHtml(original);
if (transformed.equals(original)) {
outgoing.writeHead(response.statusCode ?? 502, headers);
outgoing.end(original);
return;
}
injectedRuntimeDocumentCount += 1;
const transformedHeaders = {
...headers,
"content-length": String(transformed.length),
};
delete transformedHeaders["transfer-encoding"];
outgoing.writeHead(response.statusCode ?? 502, transformedHeaders);
outgoing.end(transformed);
});
} else {
outgoing.writeHead(response.statusCode ?? 502, headers);
outgoing.flushHeaders();
response.pipe(outgoing);
}
response.once("error", (error) => {
if (!outgoing.destroyed) outgoing.destroy(error);
destroyUpstream(error);
});
response.once("aborted", () => destroyUpstream());
},
);
upstreamRequests.add(upstream);
const destroyUpstream = (error?: Error) => {
if (!upstream.destroyed) upstream.destroy(error);
if (upstreamResponse && !upstreamResponse.destroyed) upstreamResponse.destroy(error);
};
const destroyIncompleteUpstream = () => {
if (!incoming.complete) destroyUpstream();
};
const removeUpstream = () => {
upstreamRequests.delete(upstream);
incoming.off("aborted", destroyUpstream);
incoming.off("error", destroyUpstream);
incoming.off("close", destroyIncompleteUpstream);
outgoing.off("close", destroyUpstream);
outgoing.off("error", destroyUpstream);
};
incoming.once("aborted", destroyUpstream);
incoming.once("error", destroyUpstream);
incoming.once("close", destroyIncompleteUpstream);
outgoing.once("close", destroyUpstream);
outgoing.once("error", destroyUpstream);
upstream.once("close", removeUpstream);
upstream.on("error", (error) => {
if (outgoing.destroyed) return;
if (!outgoing.headersSent) {
outgoing.writeHead(502);
outgoing.end();
} else {
outgoing.destroy(error);
}
});
incoming.pipe(upstream);
},
);
const port = await listen(server);
certificate.port = port;
const sockets = new Set<Socket>();
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
const origin = `https://${certificate.hostname}:${port}`;
const host = `${certificate.hostname}:${port}`;
return {
origin,
host,
cookieName: CANVAS_PROXY_COOKIE_NAME,
setAuthentication: async (context) => {
await context.addCookies([
{
name: CANVAS_PROXY_COOKIE_NAME,
value: "valid",
url: origin,
httpOnly: true,
secure: true,
sameSite: "Lax",
},
]);
},
clearAuthentication: async (context) => {
await context.clearCookies({ name: CANVAS_PROXY_COOKIE_NAME });
},
count: (kind, authenticated) =>
observations.filter(
(observation) =>
observation.kind === kind &&
(authenticated === undefined || observation.authenticated === authenticated),
).length,
observations: () => observations.map((observation) => ({ ...observation })),
activeUpstreamRequests: () => upstreamRequests.size,
injectedRuntimeDocuments: () => injectedRuntimeDocumentCount,
close: async () => {
await closeServer(server, sockets, upstreamRequests, upstreamResponses);
fs.rmSync(certificate.directory, { recursive: true, force: true });
},
};
}
function proxyResponseHeaders(
source: IncomingMessage["headers"],
stripNoTransform: boolean,
): IncomingMessage["headers"] {
const headers = { ...source };
if (!stripNoTransform) return headers;
const cacheControl = headerString(headers["cache-control"]);
if (!cacheControl) return headers;
const directives = cacheControl
.split(",")
.map((directive) => directive.trim())
.filter((directive) => directive.toLowerCase() !== "no-transform");
if (directives.length === 0) delete headers["cache-control"];
else headers["cache-control"] = directives.join(", ");
return headers;
}
function headerString(value: string | string[] | undefined): string {
return Array.isArray(value) ? value.join(", ") : (value ?? "");
}
function hasNoTransformDirective(value: string | string[] | undefined): boolean {
return headerString(value)
.split(",")
.some((directive) => directive.trim().toLowerCase() === "no-transform");
}
function isHtmlContentType(value: string | string[] | undefined): boolean {
return headerString(value).toLowerCase().startsWith("text/html");
}
function injectRuntimeHtml(document: Buffer): Buffer {
const source = document.toString("utf8");
const script = '<script src="https://static.cloudflareinsights.com/beacon.min.js"></script>';
const closingHead = /<\/head\s*>/i.exec(source);
const transformed = closingHead
? `${source.slice(0, closingHead.index)}${script}${source.slice(closingHead.index)}`
: `${source}${script}`;
return Buffer.from(transformed, "utf8");
}
function runtimeRequestKind(pathname: string, method: string): RuntimeRequestKind | null {
if (!pathname.startsWith(runtimePrefix)) return null;
const tokenAndPath = pathname.slice(runtimePrefix.length);
const separator = tokenAndPath.indexOf("/");
const requestPath = separator < 0 ? "" : tokenAndPath.slice(separator + 1);
if (requestPath === "") return "runtime-entry";
if (requestPath === "_kandev/host-runtime.js") return "runtime-host";
if (requestPath === "_kandev/v1/context") return "context";
if (requestPath === "_kandev/v1/events") return "events";
if (requestPath.startsWith("_kandev/v1/data/") || requestPath.startsWith("_kandev/v1/state")) {
return method === "GET" ? "data" : "write";
}
return "runtime-asset";
}
function hasAuthenticationCookie(cookieHeader: string | undefined): boolean {
return new RegExp(`(?:^|;\\s*)${CANVAS_PROXY_COOKIE_NAME}=valid(?:;|$)`).test(cookieHeader ?? "");
}
function createCertificate(): {
directory: string;
keyPath: string;
certPath: string;
hostname: string;
port: number;
} {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-canvas-auth-proxy-cert-"));
const keyPath = path.join(directory, "key.pem");
const certPath = path.join(directory, "cert.pem");
const configPath = path.join(directory, "openssl.cnf");
fs.writeFileSync(
configPath,
[
"[req]",
"distinguished_name = req_distinguished_name",
"x509_extensions = v3_req",
"prompt = no",
"[req_distinguished_name]",
"CN = 127.0.0.1",
"[v3_req]",
"subjectAltName = @alt_names",
"[alt_names]",
"IP.1 = 127.0.0.1",
"DNS.1 = localhost",
"",
].join("\n"),
);
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
keyPath,
"-out",
certPath,
"-config",
configPath,
"-extensions",
"v3_req",
"-days",
"1",
],
{ stdio: "ignore" },
);
return { directory, keyPath, certPath, hostname: "127.0.0.1", port: 0 };
}
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 authenticated proxy did not receive a port"));
return;
}
resolve(address.port);
});
});
}
function closeServer(
server: TLSServer,
sockets: Set<Socket>,
upstreamRequests: Set<ClientRequest>,
upstreamResponses: Set<IncomingMessage>,
): Promise<void> {
return new Promise((resolve, reject) => {
for (const request of upstreamRequests) request.destroy();
for (const response of upstreamResponses) response.destroy();
for (const socket of sockets) socket.destroy();
server.close((error) => (error ? reject(error) : resolve()));
});
}