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
This commit is contained in:
Tommy D. Rossi
2026-02-17 15:54:39 +01:00
parent 9008920c95
commit 05700fa53b
5 changed files with 231 additions and 2 deletions
+1 -1
View File
@@ -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 <token>` and every WebSocket connection needs `?token=<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 <token>` or `?token=<token>`, and every `/cdp` WebSocket connection needs `?token=<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.
+8
View File
@@ -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 <token>` or `?token=<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
+1 -1
View File
@@ -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",
+54
View File
@@ -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<Parameters<typeof app.use>[1]>[0], next: () => Promise<void>) => {
// 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 }
+167
View File
@@ -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<string, string>
}) => {
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)
})
})