History

fix(v2): harden callback auth and remove secret fallbacks

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.

Rhys Sullivan committed 8 months ago

Commit 6ead7751 parent 18330639 files · +192 −63Browse files

Unified · Split

9 files
  1. apps/api/api/parse-openapi.ts +9 −3
  2. apps/convex/convex/control_plane/tool_registry.ts +7 −4
  3. apps/convex/convex/credential_resolver.ts +8 −6
  4. apps/convex/convex/http_security.ts +69 −0
  5. apps/convex/convex/mcp_auth.ts +27 −14
  6. apps/convex/convex/runtimeCallbacks.ts +21 −28
  7. apps/convex/convex/runtime_execution_port.ts +1 −1
  8. apps/pm/src/tool-call-handler.ts +46 −0
  9. packages/runtime-cloudflare-worker-loader/src/index.ts +4 −7
apps/api/api/parse-openapi.ts+9−3
@@ -749,9 +749,7 @@ const parseEndpoint = async (request: any, response: any) => { · 748 unmodified lines
749749 || process.env.NEXT_PUBLIC_CONVEX_URL?.trim()
750750 || "";
751751 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() || "";
755753
756754 const shouldIngest =
757755 workspaceId.length > 0
@@ -759,6 +757,14 @@ const parseEndpoint = async (request: any, response: any) => { · 1 unmodified line
759757 && sourceName.length > 0
760758 && convexUrl.length > 0;
761759
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+
762768 if (shouldIngest) {
763769 const ingested = await toIngestPayload(openApiSpec, {
764770 convexUrl,
apps/convex/convex/control_plane/tool_registry.ts+7−4
@@ -423,10 +423,13 @@ export const ingestOpenApiManifest = action({ · 422 unmodified lines
423423 },
424424 handler: async (ctx, args) => {
425425 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) {
430433 throw new Error("Unauthorized OpenAPI ingest request");
431434 }
432435
apps/convex/convex/credential_resolver.ts+8−6
@@ -18,10 +18,12 @@ import { v } from "convex/values"; · 17 unmodified lines
1818 import * as Effect from "effect/Effect";
1919 import * as Schema from "effect/Schema";
2020
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";
2323 import { decryptSecretValue } from "./credential_crypto";
2424
25+const runtimeInternal = internal as any;
26+
2527 const decodeSourceCredentialBinding = Schema.decodeUnknownSync(
2628 SourceCredentialBindingSchema,
2729 );
@@ -34,7 +36,7 @@ const stripConvexSystemFields = ( · 6 unmodified lines
3436 return rest;
3537 };
3638
37−export const listCredentialBindingsForSource = query({
39+export const listCredentialBindingsForSource = internalQuery({
3840 args: {
3941 workspaceId: v.string(),
4042 sourceKey: v.string(),
@@ -69,7 +71,7 @@ export const listCredentialBindingsForSource = query({ · 28 unmodified lines
6971 },
7072 });
7173
72−export const listOAuthTokensForSource = query({
74+export const listOAuthTokensForSource = internalQuery({
7375 args: {
7476 workspaceId: v.string(),
7577 sourceId: v.string(),
@@ -140,7 +142,7 @@ export const createConvexResolveToolCredentials = ( · 64 unmodified lines
140142 const bindings = yield* runQueryEffect(
141143 "list_credential_bindings",
142144 () =>
143− ctx.runQuery(api.credential_resolver.listCredentialBindingsForSource, {
145+ ctx.runQuery(runtimeInternal.credential_resolver.listCredentialBindingsForSource, {
144146 workspaceId: context.workspaceId,
145147 sourceKey: context.sourceKey,
146148 organizationId: context.organizationId ?? undefined,
@@ -161,7 +163,7 @@ export const createConvexResolveToolCredentials = ( · 14 unmodified lines
161163 binding.provider === "oauth2" && sourceId
162164 ? yield* runQueryEffect("list_oauth_tokens", () =>
163165 ctx
164− .runQuery(api.credential_resolver.listOAuthTokensForSource, {
166+ .runQuery(runtimeInternal.credential_resolver.listOAuthTokensForSource, {
165167 workspaceId: context.workspaceId,
166168 sourceId,
167169 })
apps/convex/convex/http_security.ts+69−0
@@ -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+};
apps/convex/convex/mcp_auth.ts+27−14
@@ -21,26 +21,39 @@ const isTruthyEnvValue = (value: string | undefined): boolean => { · 20 unmodified lines
2121 return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
2222 };
2323
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)) {
3233 return true;
3334 }
3435
35− if (
36− deploymentMode === "self-hosted"
37− || deploymentMode === "self_hosted"
38− || deploymentMode === "selfhosted"
39− ) {
36+ if (["0", "false", "no", "off"].includes(normalized)) {
4037 return false;
4138 }
4239
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;
4457 };
4558
4659 const getMcpAuthorizationServer = (): string | null =>
apps/convex/convex/runtimeCallbacks.ts+21−28
@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; · 12 unmodified lines
1313
1414 import { internal } from "./_generated/api";
1515 import { httpAction, type ActionCtx } from "./_generated/server";
16+import { enforceInternalSecret } from "./http_security";
1617 import { createConvexSourceToolRegistry } from "./source_tool_registry";
1718
1819 class RuntimeToolCallBadRequestError extends Data.TaggedError(
@@ -44,13 +45,6 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 25 unmodified lines
4445
4546 const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
4647
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−
5448 const badRequest = (message: string): Response =>
5549 Response.json(
5650 {
@@ -91,40 +85,31 @@ const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallR · 34 unmodified lines
9185 );
9286 });
9387
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−
9988 const resolveWorkspaceIdForToolCall = (
10089 ctx: ActionCtx,
101− request: Request,
10290 input: RuntimeToolCallRequest,
103−): Effect.Effect<string, never> =>
91+): Effect.Effect<string, RuntimeToolCallBadRequestError> =>
10492 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−
11593 const runWorkspaceId = yield* Effect.tryPromise({
11694 try: () =>
11795 ctx.runQuery(internal.task_runs.getTaskRunWorkspaceId, {
11896 runId: input.runId,
11997 }),
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+ });
122104
123105 if (typeof runWorkspaceId === "string" && runWorkspaceId.trim().length > 0) {
124106 return runWorkspaceId.trim();
125107 }
126108
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+ });
128113 });
129114
130115 const handleToolCallHttpEffect = (
@@ -132,8 +117,16 @@ const handleToolCallHttpEffect = ( · 1 unmodified line
132117 request: Request,
133118 ): Effect.Effect<Response, never> =>
134119 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+
135128 const input = yield* decodeToolCallRequest(request);
136− const workspaceId = yield* resolveWorkspaceIdForToolCall(ctx, request, input);
129+ const workspaceId = yield* resolveWorkspaceIdForToolCall(ctx, input);
137130
138131 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
139132
apps/convex/convex/runtime_execution_port.ts+1−1
@@ -47,7 +47,7 @@ export const createExecuteRuntimeRunInConvex = ( · 46 unmodified lines
4747 operation: "runtime_available",
4848 message: `Runtime '${convexRuntimeAdapter.kind}' is not configured in this convex process.`,
4949 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.",
5151 });
5252 }
5353
apps/pm/src/tool-call-handler.ts+46−0
@@ -36,6 +36,47 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 35 unmodified lines
3636
3737 const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
3838
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+
3980 const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError =>
4081 new PmToolCallHttpRequestError({
4182 message: "Invalid runtime callback request body",
@@ -79,6 +120,11 @@ export const createPmToolCallHttpHandler = ( · 37 unmodified lines
79120 ): ((request: Request) => Promise<Response>) => {
80121 return async (request: Request) => {
81122 try {
123+ const blocked = enforceToolCallSecurity(request);
124+ if (blocked) {
125+ return blocked;
126+ }
127+
82128 const input = await decodeToolCallRequest(request);
83129 const result = await handleToolCall(input);
84130 return Response.json(result, { status: 200 });
packages/runtime-cloudflare-worker-loader/src/index.ts+4−7
@@ -21,7 +21,7 @@ export type CloudflareWorkerLoaderConfig = { · 20 unmodified lines
2121 runUrl: string;
2222 authToken: string;
2323 callbackUrl: string;
24− callbackInternalSecret: string | null;
24+ callbackInternalSecret: string;
2525 requestTimeoutMs: number;
2626 };
2727
@@ -126,10 +126,7 @@ const resolveConfig = ( · 98 unmodified lines
126126 );
127127 const callbackInternalSecret =
128128 options.callbackInternalSecret ??
129− normalizeString(
130− env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET ??
131− env.EXECUTOR_INTERNAL_TOKEN,
132− ) ??
129+ normalizeString(env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET) ??
133130 null;
134131
135132 const requestTimeoutMs =
@@ -139,7 +136,7 @@ const resolveConfig = ( · 3 unmodified lines
139136 defaultRequestTimeoutMs,
140137 );
141138
142− if (!runUrl || !authToken || !callbackUrl) {
139+ if (!runUrl || !authToken || !callbackUrl || !callbackInternalSecret) {
143140 return null;
144141 }
145142
@@ -163,7 +160,7 @@ const makeDispatchRequest = ( · 17 unmodified lines
163160 timeoutMs: input.timeoutMs,
164161 callback: {
165162 url: config.callbackUrl,
166− internalSecret: config.callbackInternalSecret ?? undefined,
163+ internalSecret: config.callbackInternalSecret,
167164 },
168165 };
169166