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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user