import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { createServer as createHttpServer, type Server } from "node:http";
import type { AddressInfo, Server as NetServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { openScreenCapability } from "@rakazo/core/node/screen-capability";
import { createServer, type Plugin, preview, type ViteDevServer } from "vite";
import { addScreenProxyCapability } from "../../api/src/screen-proxy";
const scope = {
botId: "bot",
computerId: "computer",
botGeneration: 0,
computerGeneration: 0,
controlLeaseId: null,
};
const secret = "fake-screen-proxy-browser-test-secret";
const screenHtml = `
Fake screen
`;
async function listen(server: NetServer) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
async function close(server: Server) {
server.closeAllConnections();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
for (const mode of ["development", "preview"] as const) {
test.describe(`screen response isolation in ${mode}`, () => {
let origin: string;
let screenUrl: string;
let stop: () => Promise;
let authorized = true;
let assetAttempts = 0;
test.beforeAll(async () => {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-screen-test-"));
await mkdir(path.join(root, "dist"));
const upstream = createHttpServer((req, res) => {
// Even a listener explicitly requesting same-origin access cannot loosen the proxy policy.
res.setHeader(
"Content-Security-Policy",
`sandbox allow-scripts allow-same-origin allow-pointer-lock; frame-ancestors ${origin}`,
);
res.setHeader("Set-Cookie", "app-session=attacker; Path=/");
if (req.url === "/core/rfb.js") {
if (assetAttempts++ === 0) {
res.destroy();
return;
}
res.setHeader("Content-Type", "text/javascript");
res.end('export const loaded = "module loaded";');
} else {
res.setHeader("Content-Type", "text/html");
res.end(screenHtml);
}
});
upstream.on("upgrade", (req, socket) => {
const accept = createHash("sha1")
.update(`${req.headers["sec-websocket-key"]}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\nSet-Cookie: app-session=attacker; Path=/\r\n\r\n`,
);
socket.write(Buffer.from([0x81, 2, 111, 107]));
socket.on("data", () => socket.end(Buffer.from([0x88, 0])));
socket.on("error", () => socket.destroy());
socket.on("end", () => socket.destroy());
});
const upstreamOrigin = await listen(upstream);
const appFixture: Plugin = {
name: "fake-app-origin",
configureServer: installApp,
configurePreviewServer: installApp,
};
function installApp(server: Pick) {
server.middlewares.use((req, res, next) => {
if (req.url === "/app") {
res.setHeader("Content-Type", "text/html");
res.end(
'Fake app',
);
} else if (req.url === "/session") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ cookie: req.headers.cookie ?? null }));
} else next();
});
}
const authority = createHttpServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const body = JSON.parse(Buffer.concat(chunks).toString());
const capability = openScreenCapability(body.path, secret);
if (!authorized || !capability || req.headers.authorization !== `Bearer ${secret}`) {
res.writeHead(403).end();
return;
}
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(capability.target));
});
const previousApi = process.env.API_PROXY_TARGET;
process.env.API_PROXY_TARGET = await listen(authority);
const previousSecret = process.env.SCREEN_PROXY_SECRET;
process.env.SCREEN_PROXY_SECRET = secret;
try {
// Load the real configuration: both production and development hooks must install the policy.
const config = {
root,
configFile: path.resolve(import.meta.dirname, "../vite.config.ts"),
mode: "test",
plugins: [appFixture],
logLevel: "silent" as const,
server: { host: "127.0.0.1", port: 0, hmr: false as const },
preview: { host: "127.0.0.1", port: 0 },
};
const server = mode === "development" ? await createServer(config) : await preview(config);
// listen() replaces zero with Vite's default port; bind the HTTP server directly instead.
if ("listen" in server) await listen(server.httpServer!);
origin = `http://127.0.0.1:${(server.httpServer!.address() as AddressInfo).port}`;
// Exercise the same capability issuer used by the production API, with a configured web origin.
screenUrl = addScreenProxyCapability(`${upstreamOrigin}/embed.html`, secret, origin, scope);
stop = async () => {
await server.close();
await close(upstream);
await close(authority);
await rm(root, { recursive: true, force: true });
};
} finally {
if (previousApi === undefined) delete process.env.API_PROXY_TARGET;
else process.env.API_PROXY_TARGET = previousApi;
if (previousSecret === undefined) delete process.env.SCREEN_PROXY_SECRET;
else process.env.SCREEN_PROXY_SECRET = previousSecret;
}
});
test.afterAll(async () => {
await stop?.();
});
test("direct navigation cannot read application data while assets and WebSockets work", async ({
page,
}) => {
await page.goto(`${origin}/app`);
const response = await page.goto(screenUrl);
await expect(page.locator("body")).toContainText('"socket":"ok"');
expect(assetAttempts).toBeGreaterThanOrEqual(2);
const result = JSON.parse(await page.locator("body").innerText());
expect(result).toMatchObject({
asset: "module loaded",
socket: "ok",
cookie: "blocked",
storage: "blocked",
api: "blocked",
});
expect(response?.headers()["content-security-policy"]).toContain(
"sandbox allow-scripts allow-pointer-lock",
);
expect(
(await page.context().cookies(origin)).find((c) => c.name === "app-session")?.value,
).toBe("fake-session");
});
test("revocation blocks replay and closes an already connected socket", async ({
page,
request,
}) => {
await page.goto(`${origin}/app`);
await page.evaluate(async (url) => {
const target = new URL(url);
target.protocol = "ws:";
target.pathname = target.pathname.replace("/embed.html", "/hold");
const socket = new WebSocket(target);
const state = window as unknown as { screenClosed: boolean };
state.screenClosed = false;
socket.onclose = () => {
state.screenClosed = true;
};
await new Promise((resolve, reject) => {
socket.onmessage = () => resolve();
socket.onerror = () => reject(new Error("socket failed"));
});
}, screenUrl);
authorized = false;
try {
expect((await request.get(screenUrl)).status()).toBe(403);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { screenClosed: boolean }).screenClosed),
)
.toBe(true);
} finally {
authorized = true;
}
});
for (const sandbox of ["allow-scripts allow-pointer-lock", null]) {
test(`iframe remains isolated with sandbox attribute ${sandbox ?? "absent"}`, async ({
page,
}) => {
await page.goto(`${origin}/app`);
await page.evaluate(
({ url, sandbox }) => {
const frame = document.createElement("iframe");
if (sandbox !== null) frame.setAttribute("sandbox", sandbox);
frame.src = url;
document.body.append(frame);
},
{ url: screenUrl, sandbox },
);
const body = page.frameLocator("iframe").locator("body");
await expect(body).toContainText('"socket":"ok"');
expect(JSON.parse(await body.innerText())).toEqual({
asset: "module loaded",
socket: "ok",
cookie: "blocked",
storage: "blocked",
parent: "blocked",
api: "blocked",
});
});
}
});
}