From 05700fa53b6b81708e255ab7beb44e4dd417f9ee Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Feb 2026 15:54:39 +0100 Subject: [PATCH] release: playwriter@0.0.63 Security hardening for privileged HTTP routes (/cli/*, /recording/*). - Block cross-origin browser requests via Sec-Fetch-Site header validation (browsers always set this forbidden header; Node.js/curl clients don't send it, so they're unaffected) - Reject POST requests without Content-Type: application/json to prevent the CORS simple-request bypass (text/plain POST skips CORS preflight entirely) - Enforce token authentication on /cli/* and /recording/* when --token is set (remote access mode), matching the behavior documented in remote-access.md - Update remote-access.md to clarify which routes require token auth - Add security regression tests covering all attack vectors: Sec-Fetch-Site blocking, Content-Type enforcement, token validation, and Node.js client pass-through --- docs/remote-access.md | 2 +- playwriter/CHANGELOG.md | 8 ++ playwriter/package.json | 2 +- playwriter/src/cdp-relay.ts | 54 ++++++++++ playwriter/test/security.test.ts | 167 +++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 2 deletions(-) diff --git a/docs/remote-access.md b/docs/remote-access.md index 95c1ddf..0fb0171 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -164,7 +164,7 @@ done **Traforo URLs are non-guessable.** Each tunnel gets a unique ID (random UUID by default). Nobody can discover your tunnel by scanning. -**Token authentication is required.** When `playwriter serve` binds to `0.0.0.0`, it refuses to start without a `--token`. Every HTTP request needs `Authorization: Bearer ` and every WebSocket connection needs `?token=`. Without the correct token, the relay returns 401. +**Token authentication is required.** When `playwriter serve` binds to `0.0.0.0`, it refuses to start without a `--token`. Every privileged HTTP request (`/cli/*`, `/recording/*`) needs `Authorization: Bearer ` or `?token=`, and every `/cdp` WebSocket connection needs `?token=`. Without the correct token, the relay returns 401. **Extension endpoint is localhost-only.** The `/extension` WebSocket endpoint only accepts connections from `127.0.0.1` or `::1`. A remote attacker cannot impersonate the extension even with the token. diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 3a4145c..61500c6 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.0.63 + +### Security + +- **Harden privileged HTTP routes against cross-origin attacks**: Added route-level middleware on `/cli/*` and `/recording/*` that blocks cross-origin browser requests via `Sec-Fetch-Site` header validation, rejects POST requests without `Content-Type: application/json` (prevents the CORS preflight bypass via `text/plain`), and enforces token authentication when token mode is enabled. Previously, CORS alone was relied upon, but CORS only blocks reading responses — it does not prevent "simple" POST requests from executing side effects like `/cli/execute`. +- **Token enforcement on HTTP routes**: When `--token` is set (remote access mode), `/cli/*` and `/recording/*` routes now require `Authorization: Bearer ` or `?token=`, matching the behavior already documented in remote-access.md. +- **Security regression tests**: Added tests covering Sec-Fetch-Site blocking, Content-Type enforcement, token validation on privileged routes, and pass-through for legitimate Node.js clients. + ## 0.0.62 ### Features diff --git a/playwriter/package.json b/playwriter/package.json index b4de21f..86d6dd2 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -1,7 +1,7 @@ { "name": "playwriter", "description": "", - "version": "0.0.62", + "version": "0.0.63", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 1391497..84872a4 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -1441,6 +1441,60 @@ export async function startPlayWriterCDPRelayServer({ return executorManager } + // ============================================================================ + // Security middleware for privileged HTTP routes (/cli/*, /recording/*) + // + // CORS alone does NOT prevent cross-origin POST attacks. Browsers skip the + // preflight for "simple" requests (POST + Content-Type: text/plain), so a + // malicious website can fire-and-forget a POST to localhost:19988/cli/execute + // and the code executes before CORS even enters the picture. + // + // Two layers of defense: + // 1. Sec-Fetch-Site: browsers set this forbidden header on every request. + // If present and not "same-origin"/"none", it's a cross-origin browser + // request → reject. Node.js clients don't send it → unaffected. + // 2. Content-Type must be application/json on POST. This forces a CORS + // preflight as a fallback, which our CORS policy already blocks. + // 3. When token mode is enabled (remote access), require the token. + // ============================================================================ + const privilegedRouteMiddleware = async (c: Parameters[1]>[0], next: () => Promise) => { + // Block cross-origin browser requests via Sec-Fetch-Site header. + // Browsers always set this forbidden header; it cannot be spoofed. + // Non-browser clients (Node.js, curl, MCP) don't send it. + const secFetchSite = c.req.header('sec-fetch-site') + if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') { + logger?.log(pc.red(`Rejecting ${c.req.path}: cross-origin browser request (Sec-Fetch-Site: ${secFetchSite})`)) + return c.text('Forbidden - Cross-origin requests not allowed', 403) + } + + // Require application/json on POST to force CORS preflight as backup defense. + // A text/plain POST is a "simple request" that skips preflight entirely. + if (c.req.method === 'POST') { + const contentType = c.req.header('content-type') || '' + if (!contentType.includes('application/json')) { + logger?.log(pc.red(`Rejecting ${c.req.path}: Content-Type must be application/json, got: ${contentType}`)) + return c.text('Content-Type must be application/json', 415) + } + } + + // When token mode is enabled (remote/serve mode), require authentication. + if (token) { + const authHeader = c.req.header('authorization') || '' + const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null + const url = new URL(c.req.url, 'http://localhost') + const queryToken = url.searchParams.get('token') + if (bearerToken !== token && queryToken !== token) { + logger?.log(pc.red(`Rejecting ${c.req.path}: invalid or missing token`)) + return c.text('Unauthorized', 401) + } + } + + return next() + } + + app.use('/cli/*', privilegedRouteMiddleware) + app.use('/recording/*', privilegedRouteMiddleware) + app.post('/cli/execute', async (c) => { try { const body = await c.req.json() as { sessionId: string | number; code: string; timeout?: number } diff --git a/playwriter/test/security.test.ts b/playwriter/test/security.test.ts index 48f97b7..a846f9d 100644 --- a/playwriter/test/security.test.ts +++ b/playwriter/test/security.test.ts @@ -108,4 +108,171 @@ describe('Security Tests', () => { // Based on implementation, usually it checks if it starts with chrome-extension:// await expect(tryConnectExtension()).rejects.toThrow(/Unexpected response: (400|401|403)/) }) + + // ========================================================================= + // Privileged HTTP route hardening (/cli/*, /recording/*) + // + // These tests verify that cross-origin browser requests are blocked even + // without CORS preflight (the "simple request" attack vector where POST + + // Content-Type: text/plain bypasses CORS entirely). + // ========================================================================= + + const httpRequest = ({ path, method = 'POST', headers = {} }: { + path: string + method?: string + headers?: Record + }) => { + return fetch(`http://127.0.0.1:${TEST_PORT}${path}`, { + method, + headers, + body: method === 'POST' ? JSON.stringify({ sessionId: '1', code: 'true' }) : undefined, + }) + } + + it('should block cross-origin browser requests to /cli/* via Sec-Fetch-Site', async () => { + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) + + // cross-site browser request → 403 + const crossSite = await httpRequest({ + path: '/cli/execute', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' }, + }) + expect(crossSite.status).toBe(403) + + // same-site but not same-origin → 403 + const sameSite = await httpRequest({ + path: '/cli/execute', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' }, + }) + expect(sameSite.status).toBe(403) + }) + + it('should block cross-origin browser requests to /recording/* via Sec-Fetch-Site', async () => { + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) + + const res = await httpRequest({ + path: '/recording/status', + method: 'GET', + headers: { 'Sec-Fetch-Site': 'cross-site' }, + }) + expect(res.status).toBe(403) + }) + + it('should block POST with non-JSON Content-Type (text/plain bypass)', async () => { + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) + + // text/plain is the classic CORS preflight bypass + const textPlain = await httpRequest({ + path: '/cli/execute', + headers: { 'Content-Type': 'text/plain' }, + }) + expect(textPlain.status).toBe(415) + + // form-urlencoded is another simple request type + const formData = await httpRequest({ + path: '/cli/execute', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(formData.status).toBe(415) + + // missing Content-Type entirely + const noContentType = await httpRequest({ + path: '/cli/execute', + headers: {}, + }) + expect(noContentType.status).toBe(415) + }) + + it('should allow requests without Sec-Fetch-Site (Node.js/CLI clients)', async () => { + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) + + // Node.js clients don't send Sec-Fetch-Site, only Content-Type: application/json. + // Request should pass the middleware (will 404 because no session exists, which is fine). + const res = await httpRequest({ + path: '/cli/execute', + headers: { 'Content-Type': 'application/json' }, + }) + // 404 = passed middleware, session just doesn't exist + expect(res.status).toBe(404) + }) + + it('should allow same-origin browser requests', async () => { + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) + + const res = await httpRequest({ + path: '/cli/execute', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, + }) + // 404 = passed middleware + expect(res.status).toBe(404) + }) + + it('should enforce token on /cli/* and /recording/* when token mode is enabled', async () => { + const secretToken = 'test-secret-token' + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, token: secretToken, logger }) + + // No token → 401 + const noToken = await httpRequest({ + path: '/cli/sessions', + method: 'GET', + headers: {}, + }) + expect(noToken.status).toBe(401) + + // Wrong token → 401 + const wrongToken = await httpRequest({ + path: '/cli/sessions', + method: 'GET', + headers: { 'Authorization': 'Bearer wrong-token' }, + }) + expect(wrongToken.status).toBe(401) + + // Correct token via Authorization header → pass middleware + const bearerOk = await httpRequest({ + path: '/cli/sessions', + method: 'GET', + headers: { 'Authorization': `Bearer ${secretToken}` }, + }) + expect(bearerOk.status).toBe(200) + + // Correct token via query param → pass middleware + const queryOk = await fetch( + `http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`, + ) + expect(queryOk.status).toBe(200) + + // Token also enforced on /recording/* + const recordingNoToken = await httpRequest({ + path: '/recording/status', + method: 'GET', + headers: {}, + }) + expect(recordingNoToken.status).toBe(401) + + const recordingWithToken = await httpRequest({ + path: '/recording/status', + method: 'GET', + headers: { 'Authorization': `Bearer ${secretToken}` }, + }) + expect(recordingWithToken.status).toBe(200) + }) + + it('should not require token on /cli/* when no token is configured', async () => { + const logger = createFileLogger() + server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) + + // Without token mode, /cli/sessions should work with just proper headers + const res = await httpRequest({ + path: '/cli/sessions', + method: 'GET', + headers: {}, + }) + expect(res.status).toBe(200) + }) })