Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/contributing/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ audit for findings and rationale.

## Public connector routes are WebSocket-only

The Worker entrypoint (`packages/worker/src/index.ts`) only forwards connector
route requests (`/connectors/...`) when the request carries a `WebSocket`
upgrade header. Non-upgrade HTTP requests are rejected with `404` before
reaching the Durable Object.
The Worker entrypoint (`packages/worker/src/index.ts`) only forwards user-scoped
connector route requests (`/connectors/u/{userId}/{kind}/{instanceId}`) when the
request carries a `WebSocket` upgrade header. Non-upgrade HTTP requests and
unmatched `/connectors/*` paths are rejected with `404` before reaching static
assets or the Durable Object.

As a second layer, the remote connector session Durable Object `fetch()` handler
rejects all non-WebSocket requests with `404`. Worker-internal callers use
Expand Down
4 changes: 4 additions & 0 deletions packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ const appHandler = withCors({
return stub.fetch(forwardRequest)
}

if (url.pathname.startsWith('/connectors/')) {
return new Response('Not Found', { status: 404 })
}

// Sandboxed widget iframes have an opaque origin, so JS/CSS loads become CORS fetches.
// ChatGPT/MCP Jam can render with sandbox="allow-scripts", which requires these headers.
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ test('user-scoped connector entrypoints reject unauthenticated HTTP access while
expect(websocketResponse.webSocket).toBeTruthy()
})

test('unmatched connector ingress paths do not fall through to the SPA shell', async () => {
const requests = [
createRequest('/connectors/home/default'),
createRequest('/connectors/home/default', {
headers: { Upgrade: 'websocket' },
}),
]

for (const request of requests) {
const response = await workerFetch(request)
expect(response.status).toBe(404)
await expect(response.text()).resolves.toBe('Not Found')
}
})

test('unknown maintenance endpoints return a consistent JSON 404 response', async () => {
const requests = [
createRequest('/__maintenance/reindex-skills', {
Expand Down
121 changes: 119 additions & 2 deletions tools/ci/resource-utils.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test } from 'vitest'
import { expect, test, vi } from 'vitest'

import { parseJsonc, writeGeneratedWranglerConfig } from './resource-utils.ts'

Expand Down Expand Up @@ -55,3 +55,120 @@ test('writeGeneratedWranglerConfig keeps migrations ordered by tag version', asy
await rm(tempDir, { force: true, recursive: true })
}
})

test('writeGeneratedWranglerConfig copies production asset routing to the deployed top-level config', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kody-resource-utils-'))

try {
const outConfigPath = path.join(
tempDir,
'wrangler-production.generated.json',
)
await writeGeneratedWranglerConfig({
baseConfigPath: workerWranglerConfigPath,
outConfigPath,
envName: 'production',
d1DatabaseName: 'kody',
d1DatabaseId: 'dry-run-kody',
oauthKvId: 'dry-run-kody-oauth',
bundleArtifactsKvId: 'dry-run-kody-bundle-artifacts',
})

const generatedConfigText = await readFile(outConfigPath, 'utf8')
const generatedConfig = parseJsonc<{
assets?: { run_worker_first?: Array<string> }
env?: {
production?: { assets?: { run_worker_first?: Array<string> } }
}
}>(generatedConfigText)

expect(generatedConfig.assets?.run_worker_first).toContain('/connectors/*')
expect(generatedConfig.assets).toEqual(
generatedConfig.env?.production?.assets,
)
} finally {
await rm(tempDir, { force: true, recursive: true })
}
})

test('writeGeneratedWranglerConfig copies preview asset routing to the deployed top-level config', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kody-resource-utils-'))

try {
const outConfigPath = path.join(tempDir, 'wrangler-preview.generated.json')
await writeGeneratedWranglerConfig({
baseConfigPath: workerWranglerConfigPath,
outConfigPath,
envName: 'preview',
workerName: 'kody-pr-123',
d1DatabaseName: 'kody-pr-123-db',
d1DatabaseId: 'dry-run-kody-pr-123-db',
oauthKvId: 'dry-run-kody-pr-123-oauth',
bundleArtifactsKvId: 'dry-run-kody-pr-123-bundle-artifacts',
})

const generatedConfigText = await readFile(outConfigPath, 'utf8')
const generatedConfig = parseJsonc<{
assets?: { run_worker_first?: Array<string> }
env?: {
preview?: { assets?: { run_worker_first?: Array<string> } }
}
}>(generatedConfigText)

expect(generatedConfig.assets?.run_worker_first).toContain('/connectors/*')
expect(generatedConfig.assets).toEqual(generatedConfig.env?.preview?.assets)
} finally {
await rm(tempDir, { force: true, recursive: true })
}
})

test('writeGeneratedWranglerConfig rejects invalid environment asset config', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kody-resource-utils-'))
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit')
}) as typeof process.exit)
const error = vi.spyOn(console, 'error').mockImplementation(() => {})

try {
const baseConfigPath = path.join(tempDir, 'wrangler.jsonc')
const outConfigPath = path.join(
tempDir,
'wrangler-production.generated.json',
)
await writeFile(
baseConfigPath,
JSON.stringify({
env: {
production: {
assets: [],
d1_databases: [{ binding: 'APP_DB' }],
kv_namespaces: [
{ binding: 'OAUTH_KV' },
{ binding: 'BUNDLE_ARTIFACTS_KV' },
],
},
},
}),
'utf8',
)

await expect(
writeGeneratedWranglerConfig({
baseConfigPath,
outConfigPath,
envName: 'production',
d1DatabaseName: 'kody',
d1DatabaseId: 'dry-run-kody',
oauthKvId: 'dry-run-kody-oauth',
bundleArtifactsKvId: 'dry-run-kody-bundle-artifacts',
}),
).rejects.toThrow('process.exit')
expect(error).toHaveBeenCalledWith(
`wrangler config "${baseConfigPath}" is missing "env.production.assets".`,
)
} finally {
exit.mockRestore()
error.mockRestore()
await rm(tempDir, { force: true, recursive: true })
}
})
12 changes: 12 additions & 0 deletions tools/ci/resource-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,18 @@ export async function writeGeneratedWranglerConfig({
config.name = workerName
}

const targetAssets = (targetEnv as Record<string, unknown>).assets
if (
!targetAssets ||
typeof targetAssets !== 'object' ||
Array.isArray(targetAssets)
) {
fail(
`wrangler config "${baseConfigPath}" is missing "env.${envName}.assets".`,
)
}
config.assets = { ...(targetAssets as Record<string, unknown>) }

const d1Databases = (targetEnv as Record<string, unknown>).d1_databases
if (!Array.isArray(d1Databases)) {
fail(
Expand Down
44 changes: 44 additions & 0 deletions tools/wrangler-env-config.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import path from 'node:path'
import { expect, test } from 'vitest'

import {
defaultWranglerConfigPath,
getDefaultWranglerConfigPath,
resolveWranglerConfigPath,
} from './wrangler-env-config.ts'

test('getDefaultWranglerConfigPath defaults to the repository worker config', () => {
expect(getDefaultWranglerConfigPath({})).toBe(defaultWranglerConfigPath)
})

test('getDefaultWranglerConfigPath honors WRANGLER_CONFIG for deploy wrappers', () => {
expect(
getDefaultWranglerConfigPath({
WRANGLER_CONFIG:
'/workspace/packages/worker/wrangler-production.generated.json',
}),
).toBe('/workspace/packages/worker/wrangler-production.generated.json')
})

test('resolveWranglerConfigPath preserves absolute generated config paths', () => {
expect(
resolveWranglerConfigPath(
'/workspace/packages/worker/wrangler-production.generated.json',
'/workspace',
),
).toBe('/workspace/packages/worker/wrangler-production.generated.json')
})

test('resolveWranglerConfigPath resolves relative config paths from cwd', () => {
expect(
resolveWranglerConfigPath(
'packages/worker/wrangler-production.generated.json',
'/workspace',
),
).toBe(
path.join(
'/workspace',
'packages/worker/wrangler-production.generated.json',
),
)
})
14 changes: 14 additions & 0 deletions tools/wrangler-env-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import path from 'node:path'

export const defaultWranglerConfigPath = 'packages/worker/wrangler.jsonc'

export function getDefaultWranglerConfigPath(
env: Partial<Pick<NodeJS.ProcessEnv, 'WRANGLER_CONFIG'>> = process.env,
) {
const configuredPath = env.WRANGLER_CONFIG?.trim()
return configuredPath || defaultWranglerConfigPath
}

export function resolveWranglerConfigPath(configPath: string, cwd: string) {
return path.isAbsolute(configPath) ? configPath : path.join(cwd, configPath)
}
10 changes: 8 additions & 2 deletions wrangler-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import {
stopChildProcessTree,
} from './tools/dev-process-utils.ts'
import { resolveLocalBinary } from './tools/node-runtime.ts'
import {
getDefaultWranglerConfigPath,
resolveWranglerConfigPath,
} from './tools/wrangler-env-config.ts'

const envName = process.env.CLOUDFLARE_ENV ?? 'production'
const portWaitTimeoutMs = 5000
const args = process.argv.slice(2)
const defaultWranglerConfigPath = 'packages/worker/wrangler.jsonc'
const defaultWranglerConfigPath = getDefaultWranglerConfigPath()

const hasEnvFlag = args.includes('--env') || args.includes('-e')
const isDevCommand = args[0] === 'dev'
Expand All @@ -30,7 +34,9 @@ const commandArgs = [...args]

if (
!hasConfigFlag &&
existsSync(path.join(process.cwd(), defaultWranglerConfigPath))
existsSync(
resolveWranglerConfigPath(defaultWranglerConfigPath, process.cwd()),
)
) {
commandArgs.push('--config', defaultWranglerConfigPath)
}
Expand Down
Loading