diff --git a/docs/contributing/security.md b/docs/contributing/security.md index 28ef30cf53..55665caf20 100644 --- a/docs/contributing/security.md +++ b/docs/contributing/security.md @@ -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 diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 6eee4638d4..ca67da7cd8 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -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 ( diff --git a/packages/worker/src/security/public-route-hardening.workers.test.ts b/packages/worker/src/security/public-route-hardening.workers.test.ts index 110ccc0f0f..3303286108 100644 --- a/packages/worker/src/security/public-route-hardening.workers.test.ts +++ b/packages/worker/src/security/public-route-hardening.workers.test.ts @@ -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', { diff --git a/tools/ci/resource-utils.node.test.ts b/tools/ci/resource-utils.node.test.ts index 127c8634ea..a9ec333fa0 100644 --- a/tools/ci/resource-utils.node.test.ts +++ b/tools/ci/resource-utils.node.test.ts @@ -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' @@ -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 } + env?: { + production?: { assets?: { run_worker_first?: Array } } + } + }>(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 } + env?: { + preview?: { assets?: { run_worker_first?: Array } } + } + }>(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 }) + } +}) diff --git a/tools/ci/resource-utils.ts b/tools/ci/resource-utils.ts index 11ec42ec9b..31c79fa87d 100644 --- a/tools/ci/resource-utils.ts +++ b/tools/ci/resource-utils.ts @@ -314,6 +314,18 @@ export async function writeGeneratedWranglerConfig({ config.name = workerName } + const targetAssets = (targetEnv as Record).assets + if ( + !targetAssets || + typeof targetAssets !== 'object' || + Array.isArray(targetAssets) + ) { + fail( + `wrangler config "${baseConfigPath}" is missing "env.${envName}.assets".`, + ) + } + config.assets = { ...(targetAssets as Record) } + const d1Databases = (targetEnv as Record).d1_databases if (!Array.isArray(d1Databases)) { fail( diff --git a/tools/wrangler-env-config.node.test.ts b/tools/wrangler-env-config.node.test.ts new file mode 100644 index 0000000000..64f4186312 --- /dev/null +++ b/tools/wrangler-env-config.node.test.ts @@ -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', + ), + ) +}) diff --git a/tools/wrangler-env-config.ts b/tools/wrangler-env-config.ts new file mode 100644 index 0000000000..e382b0edea --- /dev/null +++ b/tools/wrangler-env-config.ts @@ -0,0 +1,14 @@ +import path from 'node:path' + +export const defaultWranglerConfigPath = 'packages/worker/wrangler.jsonc' + +export function getDefaultWranglerConfigPath( + env: Partial> = 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) +} diff --git a/wrangler-env.ts b/wrangler-env.ts index b05638d882..547beae497 100644 --- a/wrangler-env.ts +++ b/wrangler-env.ts @@ -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' @@ -30,7 +34,9 @@ const commandArgs = [...args] if ( !hasConfigFlag && - existsSync(path.join(process.cwd(), defaultWranglerConfigPath)) + existsSync( + resolveWranglerConfigPath(defaultWranglerConfigPath, process.cwd()), + ) ) { commandArgs.push('--config', defaultWranglerConfigPath) }