Require explicit callback/ingest secrets and derive runtime callback workspace strictly from task run state. Internalize credential resolver queries and default MCP auth to enforced unless explicitly disabled.
Unified · Split
| @@ -749,9 +749,7 @@ const parseEndpoint = async (request: any, response: any) => { · 748 unmodified lines | ||
| 749 | 749 | || process.env.NEXT_PUBLIC_CONVEX_URL?.trim() |
| 750 | 750 | || ""; |
| 751 | 751 | const ingestToken = |
| 752 | − process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() | |
| 753 | − || process.env.OPENAPI_PARSE_API_TOKEN?.trim() | |
| 754 | − || ""; | |
| 752 | + process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || ""; | |
| 755 | 753 | |
| 756 | 754 | const shouldIngest = |
| 757 | 755 | workspaceId.length > 0 |
| @@ -759,6 +757,14 @@ const parseEndpoint = async (request: any, response: any) => { · 1 unmodified line | ||
| 759 | 757 | && sourceName.length > 0 |
| 760 | 758 | && convexUrl.length > 0; |
| 761 | 759 | |
| 760 | + if (shouldIngest && ingestToken.length === 0) { | |
| 761 | + json(response, 503, { | |
| 762 | + ok: false, | |
| 763 | + error: "OPENAPI_INGEST_SERVICE_TOKEN is required for ingest mode", | |
| 764 | + }); | |
| 765 | + return; | |
| 766 | + } | |
| 767 | + | |
| 762 | 768 | if (shouldIngest) { |
| 763 | 769 | const ingested = await toIngestPayload(openApiSpec, { |
| 764 | 770 | convexUrl, |
| @@ -423,10 +423,13 @@ export const ingestOpenApiManifest = action({ · 422 unmodified lines | ||
| 423 | 423 | }, |
| 424 | 424 | handler: async (ctx, args) => { |
| 425 | 425 | const expectedToken = |
| 426 | − process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() | |
| 427 | − || process.env.OPENAPI_PARSE_API_TOKEN?.trim() | |
| 428 | − || ""; | |
| 429 | − if (expectedToken.length > 0 && args.token.trim() !== expectedToken) { | |
| 426 | + process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || null; | |
| 427 | + | |
| 428 | + if (!expectedToken) { | |
| 429 | + throw new Error("OpenAPI ingest token is not configured"); | |
| 430 | + } | |
| 431 | + | |
| 432 | + if (args.token.trim() !== expectedToken) { | |
| 430 | 433 | throw new Error("Unauthorized OpenAPI ingest request"); |
| 431 | 434 | } |
| 432 | 435 | |
| @@ -18,10 +18,12 @@ import { v } from "convex/values"; · 17 unmodified lines | ||
| 18 | 18 | import * as Effect from "effect/Effect"; |
| 19 | 19 | import * as Schema from "effect/Schema"; |
| 20 | 20 | |
| 21 | −import { api } from "./_generated/api"; | |
| 22 | −import { query, type ActionCtx } from "./_generated/server"; | |
| 21 | +import { internal } from "./_generated/api"; | |
| 22 | +import { internalQuery, type ActionCtx } from "./_generated/server"; | |
| 23 | 23 | import { decryptSecretValue } from "./credential_crypto"; |
| 24 | 24 | |
| 25 | +const runtimeInternal = internal as any; | |
| 26 | + | |
| 25 | 27 | const decodeSourceCredentialBinding = Schema.decodeUnknownSync( |
| 26 | 28 | SourceCredentialBindingSchema, |
| 27 | 29 | ); |
| @@ -34,7 +36,7 @@ const stripConvexSystemFields = ( · 6 unmodified lines | ||
| 34 | 36 | return rest; |
| 35 | 37 | }; |
| 36 | 38 | |
| 37 | −export const listCredentialBindingsForSource = query({ | |
| 39 | +export const listCredentialBindingsForSource = internalQuery({ | |
| 38 | 40 | args: { |
| 39 | 41 | workspaceId: v.string(), |
| 40 | 42 | sourceKey: v.string(), |
| @@ -69,7 +71,7 @@ export const listCredentialBindingsForSource = query({ · 28 unmodified lines | ||
| 69 | 71 | }, |
| 70 | 72 | }); |
| 71 | 73 | |
| 72 | −export const listOAuthTokensForSource = query({ | |
| 74 | +export const listOAuthTokensForSource = internalQuery({ | |
| 73 | 75 | args: { |
| 74 | 76 | workspaceId: v.string(), |
| 75 | 77 | sourceId: v.string(), |
| @@ -140,7 +142,7 @@ export const createConvexResolveToolCredentials = ( · 64 unmodified lines | ||
| 140 | 142 | const bindings = yield* runQueryEffect( |
| 141 | 143 | "list_credential_bindings", |
| 142 | 144 | () => |
| 143 | − ctx.runQuery(api.credential_resolver.listCredentialBindingsForSource, { | |
| 145 | + ctx.runQuery(runtimeInternal.credential_resolver.listCredentialBindingsForSource, { | |
| 144 | 146 | workspaceId: context.workspaceId, |
| 145 | 147 | sourceKey: context.sourceKey, |
| 146 | 148 | organizationId: context.organizationId ?? undefined, |
| @@ -161,7 +163,7 @@ export const createConvexResolveToolCredentials = ( · 14 unmodified lines | ||
| 161 | 163 | binding.provider === "oauth2" && sourceId |
| 162 | 164 | ? yield* runQueryEffect("list_oauth_tokens", () => |
| 163 | 165 | ctx |
| 164 | − .runQuery(api.credential_resolver.listOAuthTokensForSource, { | |
| 166 | + .runQuery(runtimeInternal.credential_resolver.listOAuthTokensForSource, { | |
| 165 | 167 | workspaceId: context.workspaceId, |
| 166 | 168 | sourceId, |
| 167 | 169 | }) |
| @@ -0,0 +1,69 @@ | ||
| 1 | +type RequestDenyOptions = { | |
| 2 | + status: number; | |
| 3 | + message: string; | |
| 4 | +}; | |
| 5 | + | |
| 6 | +type InternalSecretOptions = { | |
| 7 | + context: string; | |
| 8 | + envVarNames: ReadonlyArray<string>; | |
| 9 | + headerName?: string; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +const trim = (value: string | undefined): string | undefined => { | |
| 13 | + const candidate = value?.trim(); | |
| 14 | + return candidate && candidate.length > 0 ? candidate : undefined; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const denyRequest = (options: RequestDenyOptions): Response => | |
| 18 | + Response.json( | |
| 19 | + { | |
| 20 | + error: options.message, | |
| 21 | + }, | |
| 22 | + { status: options.status }, | |
| 23 | + ); | |
| 24 | + | |
| 25 | +const readEnvValue = (name: string): string | undefined => trim(process.env[name]); | |
| 26 | + | |
| 27 | +const readConfiguredSecret = (envVarNames: ReadonlyArray<string>): string | null => { | |
| 28 | + for (const name of envVarNames) { | |
| 29 | + const value = readEnvValue(name); | |
| 30 | + if (value) { | |
| 31 | + return value; | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + return null; | |
| 36 | +}; | |
| 37 | + | |
| 38 | +const readRequestHeader = (request: Request, name: string): string | null => | |
| 39 | + trim(request.headers.get(name) ?? undefined) ?? null; | |
| 40 | + | |
| 41 | +export const enforceInternalSecret = ( | |
| 42 | + request: Request, | |
| 43 | + options: InternalSecretOptions, | |
| 44 | +): Response | null => { | |
| 45 | + const expectedSecret = readConfiguredSecret(options.envVarNames); | |
| 46 | + if (expectedSecret === null) { | |
| 47 | + return denyRequest({ | |
| 48 | + status: 503, | |
| 49 | + message: `${options.context} authentication secret is not configured`, | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + const providedSecret = readRequestHeader(request, options.headerName ?? "x-internal-secret"); | |
| 54 | + if (providedSecret === null) { | |
| 55 | + return denyRequest({ | |
| 56 | + status: 401, | |
| 57 | + message: `${options.context} authentication is required`, | |
| 58 | + }); | |
| 59 | + } | |
| 60 | + | |
| 61 | + if (providedSecret !== expectedSecret) { | |
| 62 | + return denyRequest({ | |
| 63 | + status: 403, | |
| 64 | + message: `${options.context} authentication failed`, | |
| 65 | + }); | |
| 66 | + } | |
| 67 | + | |
| 68 | + return null; | |
| 69 | +}; | |
| @@ -21,26 +21,39 @@ const isTruthyEnvValue = (value: string | undefined): boolean => { · 20 unmodified lines | ||
| 21 | 21 | return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | −const mcpAuthRequired = (): boolean => { | |
| 25 | − const deploymentMode = (process.env.EXECUTOR_DEPLOYMENT_MODE ?? "").trim().toLowerCase(); | |
| 26 | − if ( | |
| 27 | − deploymentMode === "cloud" | |
| 28 | − || deploymentMode === "hosted" | |
| 29 | − || deploymentMode === "production" | |
| 30 | − || deploymentMode === "prod" | |
| 31 | − ) { | |
| 24 | +const parseOptionalBooleanEnv = ( | |
| 25 | + value: string | undefined, | |
| 26 | +): boolean | null => { | |
| 27 | + const normalized = value?.trim().toLowerCase(); | |
| 28 | + if (!normalized) { | |
| 29 | + return null; | |
| 30 | + } | |
| 31 | + | |
| 32 | + if (["1", "true", "yes", "on"].includes(normalized)) { | |
| 32 | 33 | return true; |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | − if ( | |
| 36 | − deploymentMode === "self-hosted" | |
| 37 | − || deploymentMode === "self_hosted" | |
| 38 | − || deploymentMode === "selfhosted" | |
| 39 | − ) { | |
| 36 | + if (["0", "false", "no", "off"].includes(normalized)) { | |
| 40 | 37 | return false; |
| 41 | 38 | } |
| 42 | 39 | |
| 43 | − return isTruthyEnvValue(process.env.EXECUTOR_ENFORCE_MCP_AUTH); | |
| 40 | + return null; | |
| 41 | +}; | |
| 42 | + | |
| 43 | +const mcpAuthRequired = (): boolean => { | |
| 44 | + if (isTruthyEnvValue(process.env.EXECUTOR_ALLOW_UNAUTHENTICATED_MCP)) { | |
| 45 | + return false; | |
| 46 | + } | |
| 47 | + | |
| 48 | + const explicitEnforcement = parseOptionalBooleanEnv( | |
| 49 | + process.env.EXECUTOR_ENFORCE_MCP_AUTH, | |
| 50 | + ); | |
| 51 | + | |
| 52 | + if (explicitEnforcement !== null) { | |
| 53 | + return explicitEnforcement; | |
| 54 | + } | |
| 55 | + | |
| 56 | + return true; | |
| 44 | 57 | }; |
| 45 | 58 | |
| 46 | 59 | const getMcpAuthorizationServer = (): string | null => |
| @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | import { internal } from "./_generated/api"; |
| 15 | 15 | import { httpAction, type ActionCtx } from "./_generated/server"; |
| 16 | +import { enforceInternalSecret } from "./http_security"; | |
| 16 | 17 | import { createConvexSourceToolRegistry } from "./source_tool_registry"; |
| 17 | 18 | |
| 18 | 19 | class RuntimeToolCallBadRequestError extends Data.TaggedError( |
| @@ -44,13 +45,6 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 25 unmodified lines | ||
| 44 | 45 | |
| 45 | 46 | const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); |
| 46 | 47 | |
| 47 | −const readConfiguredWorkspaceId = (value: string | undefined): string => { | |
| 48 | − const normalized = value?.trim(); | |
| 49 | − return normalized && normalized.length > 0 ? normalized : "ws_local"; | |
| 50 | −}; | |
| 51 | − | |
| 52 | −const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | |
| 53 | − | |
| 54 | 48 | const badRequest = (message: string): Response => |
| 55 | 49 | Response.json( |
| 56 | 50 | { |
| @@ -91,40 +85,31 @@ const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallR · 34 unmodified lines | ||
| 91 | 85 | ); |
| 92 | 86 | }); |
| 93 | 87 | |
| 94 | −const readWorkspaceIdFromCallbackUrl = (request: Request): string | null => { | |
| 95 | − const value = new URL(request.url).searchParams.get("workspaceId")?.trim(); | |
| 96 | − return value && value.length > 0 ? value : null; | |
| 97 | −}; | |
| 98 | − | |
| 99 | 88 | const resolveWorkspaceIdForToolCall = ( |
| 100 | 89 | ctx: ActionCtx, |
| 101 | − request: Request, | |
| 102 | 90 | input: RuntimeToolCallRequest, |
| 103 | −): Effect.Effect<string, never> => | |
| 91 | +): Effect.Effect<string, RuntimeToolCallBadRequestError> => | |
| 104 | 92 | Effect.gen(function* () { |
| 105 | − const contextWorkspaceId = input.credentialContext?.workspaceId?.trim(); | |
| 106 | − if (contextWorkspaceId) { | |
| 107 | − return contextWorkspaceId; | |
| 108 | − } | |
| 109 | − | |
| 110 | − const callbackWorkspaceId = readWorkspaceIdFromCallbackUrl(request); | |
| 111 | − if (callbackWorkspaceId) { | |
| 112 | − return callbackWorkspaceId; | |
| 113 | − } | |
| 114 | − | |
| 115 | 93 | const runWorkspaceId = yield* Effect.tryPromise({ |
| 116 | 94 | try: () => |
| 117 | 95 | ctx.runQuery(internal.task_runs.getTaskRunWorkspaceId, { |
| 118 | 96 | runId: input.runId, |
| 119 | 97 | }), |
| 120 | − catch: () => null, | |
| 121 | − }).pipe(Effect.orElseSucceed(() => null)); | |
| 98 | + catch: (cause) => | |
| 99 | + new RuntimeToolCallBadRequestError({ | |
| 100 | + message: "Unable to resolve runtime callback workspace", | |
| 101 | + details: formatUnknownDetails(cause), | |
| 102 | + }), | |
| 103 | + }); | |
| 122 | 104 | |
| 123 | 105 | if (typeof runWorkspaceId === "string" && runWorkspaceId.trim().length > 0) { |
| 124 | 106 | return runWorkspaceId.trim(); |
| 125 | 107 | } |
| 126 | 108 | |
| 127 | − return fallbackWorkspaceId; | |
| 109 | + return yield* new RuntimeToolCallBadRequestError({ | |
| 110 | + message: "Runtime callback run is not associated with a workspace", | |
| 111 | + details: `runId=${input.runId}`, | |
| 112 | + }); | |
| 128 | 113 | }); |
| 129 | 114 | |
| 130 | 115 | const handleToolCallHttpEffect = ( |
| @@ -132,8 +117,16 @@ const handleToolCallHttpEffect = ( · 1 unmodified line | ||
| 132 | 117 | request: Request, |
| 133 | 118 | ): Effect.Effect<Response, never> => |
| 134 | 119 | Effect.gen(function* () { |
| 120 | + const blockedBySecret = enforceInternalSecret(request, { | |
| 121 | + context: "Runtime callback", | |
| 122 | + envVarNames: ["CLOUDFLARE_SANDBOX_CALLBACK_SECRET"], | |
| 123 | + }); | |
| 124 | + if (blockedBySecret) { | |
| 125 | + return blockedBySecret; | |
| 126 | + } | |
| 127 | + | |
| 135 | 128 | const input = yield* decodeToolCallRequest(request); |
| 136 | − const workspaceId = yield* resolveWorkspaceIdForToolCall(ctx, request, input); | |
| 129 | + const workspaceId = yield* resolveWorkspaceIdForToolCall(ctx, input); | |
| 137 | 130 | |
| 138 | 131 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId); |
| 139 | 132 | |
| @@ -47,7 +47,7 @@ export const createExecuteRuntimeRunInConvex = ( · 46 unmodified lines | ||
| 47 | 47 | operation: "runtime_available", |
| 48 | 48 | message: `Runtime '${convexRuntimeAdapter.kind}' is not configured in this convex process.`, |
| 49 | 49 | details: |
| 50 | − "Set CLOUDFLARE_SANDBOX_RUN_URL, CLOUDFLARE_SANDBOX_AUTH_TOKEN, and CLOUDFLARE_SANDBOX_CALLBACK_URL.", | |
| 50 | + "Set CLOUDFLARE_SANDBOX_RUN_URL, CLOUDFLARE_SANDBOX_AUTH_TOKEN, CLOUDFLARE_SANDBOX_CALLBACK_URL, and CLOUDFLARE_SANDBOX_CALLBACK_SECRET.", | |
| 51 | 51 | }); |
| 52 | 52 | } |
| 53 | 53 | |
| @@ -36,6 +36,47 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 35 unmodified lines | ||
| 36 | 36 | |
| 37 | 37 | const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); |
| 38 | 38 | |
| 39 | +const trim = (value: string | undefined): string | undefined => { | |
| 40 | + const candidate = value?.trim(); | |
| 41 | + return candidate && candidate.length > 0 ? candidate : undefined; | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const readHeader = (request: Request, name: string): string | null => | |
| 45 | + trim(request.headers.get(name) ?? undefined) ?? null; | |
| 46 | + | |
| 47 | +const readEnv = (name: string): string | undefined => trim(process.env[name]); | |
| 48 | + | |
| 49 | +const runtimeCallbackSecret = (): string | null => | |
| 50 | + readEnv("CLOUDFLARE_SANDBOX_CALLBACK_SECRET") ?? null; | |
| 51 | + | |
| 52 | +const toDeniedResult = (status: number, error: string): Response => | |
| 53 | + Response.json( | |
| 54 | + { | |
| 55 | + ok: false, | |
| 56 | + kind: "failed", | |
| 57 | + error, | |
| 58 | + } satisfies RuntimeToolCallResult, | |
| 59 | + { status }, | |
| 60 | + ); | |
| 61 | + | |
| 62 | +const enforceToolCallSecurity = (request: Request): Response | null => { | |
| 63 | + const expectedSecret = runtimeCallbackSecret(); | |
| 64 | + if (!expectedSecret) { | |
| 65 | + return toDeniedResult(503, "Runtime callback secret is not configured"); | |
| 66 | + } | |
| 67 | + | |
| 68 | + const providedSecret = readHeader(request, "x-internal-secret"); | |
| 69 | + if (!providedSecret) { | |
| 70 | + return toDeniedResult(401, "Runtime callback authentication is required"); | |
| 71 | + } | |
| 72 | + | |
| 73 | + if (providedSecret !== expectedSecret) { | |
| 74 | + return toDeniedResult(403, "Runtime callback authentication failed"); | |
| 75 | + } | |
| 76 | + | |
| 77 | + return null; | |
| 78 | +}; | |
| 79 | + | |
| 39 | 80 | const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError => |
| 40 | 81 | new PmToolCallHttpRequestError({ |
| 41 | 82 | message: "Invalid runtime callback request body", |
| @@ -79,6 +120,11 @@ export const createPmToolCallHttpHandler = ( · 37 unmodified lines | ||
| 79 | 120 | ): ((request: Request) => Promise<Response>) => { |
| 80 | 121 | return async (request: Request) => { |
| 81 | 122 | try { |
| 123 | + const blocked = enforceToolCallSecurity(request); | |
| 124 | + if (blocked) { | |
| 125 | + return blocked; | |
| 126 | + } | |
| 127 | + | |
| 82 | 128 | const input = await decodeToolCallRequest(request); |
| 83 | 129 | const result = await handleToolCall(input); |
| 84 | 130 | return Response.json(result, { status: 200 }); |
| @@ -21,7 +21,7 @@ export type CloudflareWorkerLoaderConfig = { · 20 unmodified lines | ||
| 21 | 21 | runUrl: string; |
| 22 | 22 | authToken: string; |
| 23 | 23 | callbackUrl: string; |
| 24 | − callbackInternalSecret: string | null; | |
| 24 | + callbackInternalSecret: string; | |
| 25 | 25 | requestTimeoutMs: number; |
| 26 | 26 | }; |
| 27 | 27 | |
| @@ -126,10 +126,7 @@ const resolveConfig = ( · 98 unmodified lines | ||
| 126 | 126 | ); |
| 127 | 127 | const callbackInternalSecret = |
| 128 | 128 | options.callbackInternalSecret ?? |
| 129 | − normalizeString( | |
| 130 | − env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET ?? | |
| 131 | − env.EXECUTOR_INTERNAL_TOKEN, | |
| 132 | − ) ?? | |
| 129 | + normalizeString(env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET) ?? | |
| 133 | 130 | null; |
| 134 | 131 | |
| 135 | 132 | const requestTimeoutMs = |
| @@ -139,7 +136,7 @@ const resolveConfig = ( · 3 unmodified lines | ||
| 139 | 136 | defaultRequestTimeoutMs, |
| 140 | 137 | ); |
| 141 | 138 | |
| 142 | − if (!runUrl || !authToken || !callbackUrl) { | |
| 139 | + if (!runUrl || !authToken || !callbackUrl || !callbackInternalSecret) { | |
| 143 | 140 | return null; |
| 144 | 141 | } |
| 145 | 142 | |
| @@ -163,7 +160,7 @@ const makeDispatchRequest = ( · 17 unmodified lines | ||
| 163 | 160 | timeoutMs: input.timeoutMs, |
| 164 | 161 | callback: { |
| 165 | 162 | url: config.callbackUrl, |
| 166 | − internalSecret: config.callbackInternalSecret ?? undefined, | |
| 163 | + internalSecret: config.callbackInternalSecret, | |
| 167 | 164 | }, |
| 168 | 165 | }; |
| 169 | 166 | |