better security for /extension
This commit is contained in:
+39
-16
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { serve } from '@hono/node-server'
|
import { serve } from '@hono/node-server'
|
||||||
|
import { getConnInfo } from '@hono/node-server/conninfo'
|
||||||
import { createNodeWebSocket } from '@hono/node-ws'
|
import { createNodeWebSocket } from '@hono/node-ws'
|
||||||
import type { WSContext } from 'hono/ws'
|
import type { WSContext } from 'hono/ws'
|
||||||
import type { Protocol } from './cdp-types.js'
|
import type { Protocol } from './cdp-types.js'
|
||||||
@@ -395,23 +396,23 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
'elnnakgjclnapgflmidlpobefkdmapdm', // Dev extension (loaded unpacked)
|
'elnnakgjclnapgflmidlpobefkdmapdm', // Dev extension (loaded unpacked)
|
||||||
]
|
]
|
||||||
|
|
||||||
function isAllowedOrigin(origin: string | undefined): boolean {
|
|
||||||
if (!origin) {
|
|
||||||
return true // Node.js clients don't send Origin
|
|
||||||
}
|
|
||||||
if (origin.startsWith('chrome-extension://')) {
|
|
||||||
const extensionId = origin.replace('chrome-extension://', '')
|
|
||||||
return ALLOWED_EXTENSION_IDS.includes(extensionId)
|
|
||||||
}
|
|
||||||
return false // Reject browser origins (http://, https://, etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/cdp/:clientId?', (c, next) => {
|
app.get('/cdp/:clientId?', (c, next) => {
|
||||||
const origin = c.req.header('origin')
|
const origin = c.req.header('origin')
|
||||||
if (!isAllowedOrigin(origin)) {
|
|
||||||
logger?.log(chalk.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
|
// Validate Origin header if present (Node.js clients don't send it)
|
||||||
return c.text('Forbidden', 403)
|
if (origin) {
|
||||||
|
if (origin.startsWith('chrome-extension://')) {
|
||||||
|
const extensionId = origin.replace('chrome-extension://', '')
|
||||||
|
if (!ALLOWED_EXTENSION_IDS.includes(extensionId)) {
|
||||||
|
logger?.log(chalk.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`))
|
||||||
|
return c.text('Forbidden', 403)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger?.log(chalk.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
|
||||||
|
return c.text('Forbidden', 403)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const url = new URL(c.req.url, 'http://localhost')
|
const url = new URL(c.req.url, 'http://localhost')
|
||||||
const providedToken = url.searchParams.get('token')
|
const providedToken = url.searchParams.get('token')
|
||||||
@@ -574,11 +575,33 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
app.get('/extension', (c, next) => {
|
app.get('/extension', (c, next) => {
|
||||||
|
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
|
||||||
|
// This prevents attackers on the network from hijacking the browser session
|
||||||
|
// even if the server is exposed via 0.0.0.0.
|
||||||
|
const info = getConnInfo(c)
|
||||||
|
const remoteAddress = info.remote.address
|
||||||
|
const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1'
|
||||||
|
|
||||||
|
if (!isLocalhost) {
|
||||||
|
logger?.log(chalk.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`))
|
||||||
|
return c.text('Forbidden - Extension must be local', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Origin Validation: Prevent browser-based attacks (CSRF).
|
||||||
|
// Browsers cannot spoof the Origin header, so this ensures the connection
|
||||||
|
// is coming from our specific Chrome Extension, not a malicious website.
|
||||||
const origin = c.req.header('origin')
|
const origin = c.req.header('origin')
|
||||||
if (!isAllowedOrigin(origin)) {
|
if (!origin || !origin.startsWith('chrome-extension://')) {
|
||||||
logger?.log(chalk.red(`Rejecting /extension WebSocket from origin: ${origin}`))
|
logger?.log(chalk.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`))
|
||||||
return c.text('Forbidden', 403)
|
return c.text('Forbidden', 403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extensionId = origin.replace('chrome-extension://', '')
|
||||||
|
if (!ALLOWED_EXTENSION_IDS.includes(extensionId)) {
|
||||||
|
logger?.log(chalk.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`))
|
||||||
|
return c.text('Forbidden', 403)
|
||||||
|
}
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}, upgradeWebSocket(() => {
|
}, upgradeWebSocket(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { startPlayWriterCDPRelayServer } from '../src/cdp-relay.js'
|
||||||
|
import { WebSocket } from 'ws'
|
||||||
|
import { killPortProcess } from 'kill-port-process'
|
||||||
|
import { createFileLogger } from '../src/create-logger.js'
|
||||||
|
|
||||||
|
const TEST_PORT = 19999
|
||||||
|
|
||||||
|
async function killProcessOnPort(port: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await killPortProcess(port)
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore if no process is running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Security Tests', () => {
|
||||||
|
let server: any = null
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
server.close()
|
||||||
|
server = null
|
||||||
|
}
|
||||||
|
await killProcessOnPort(TEST_PORT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enforce token authentication for /cdp endpoint', async () => {
|
||||||
|
const token = 'secret-token'
|
||||||
|
const logger = createFileLogger()
|
||||||
|
|
||||||
|
server = await startPlayWriterCDPRelayServer({
|
||||||
|
port: TEST_PORT,
|
||||||
|
token,
|
||||||
|
logger
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper to try connecting
|
||||||
|
const tryConnect = (tokenParam?: string) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const url = `ws://127.0.0.1:${TEST_PORT}/cdp${tokenParam ? `?token=${tokenParam}` : ''}`
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.close()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('unexpected-response', (req, res) => {
|
||||||
|
reject(new Error(`Unexpected response: ${res.statusCode}`))
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. No token -> Should fail
|
||||||
|
await expect(tryConnect()).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||||
|
|
||||||
|
// 2. Wrong token -> Should fail
|
||||||
|
await expect(tryConnect('wrong-token')).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||||
|
|
||||||
|
// 3. Correct token -> Should succeed
|
||||||
|
await expect(tryConnect(token)).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enforce localhost restrictions for /extension endpoint', async () => {
|
||||||
|
const logger = createFileLogger()
|
||||||
|
server = await startPlayWriterCDPRelayServer({
|
||||||
|
port: TEST_PORT,
|
||||||
|
logger
|
||||||
|
})
|
||||||
|
|
||||||
|
const tryConnectExtension = (origin?: string) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const url = `ws://127.0.0.1:${TEST_PORT}/extension`
|
||||||
|
const options = origin ? { headers: { Origin: origin } } : {}
|
||||||
|
const ws = new WebSocket(url, options)
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.close()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('unexpected-response', (req, res) => {
|
||||||
|
reject(new Error(`Unexpected response: ${res.statusCode}`))
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Valid chrome-extension origin -> Should succeed
|
||||||
|
// Use a valid extension ID from ALLOWED_EXTENSION_IDS in cdp-relay.ts
|
||||||
|
await expect(tryConnectExtension('chrome-extension://jfeammnjpkecdekppnclgkkffahnhfhe')).resolves.not.toThrow()
|
||||||
|
|
||||||
|
// 2. Invalid origin (e.g., http://evil.com) -> Should fail
|
||||||
|
await expect(tryConnectExtension('http://evil.com')).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||||
|
|
||||||
|
// 3. No origin -> Should likely fail if strict checking is enabled, but typically extension connection requires specific origin handling.
|
||||||
|
// Based on implementation, usually it checks if it starts with chrome-extension://
|
||||||
|
await expect(tryConnectExtension()).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user