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
749 || process.env.NEXT_PUBLIC_CONVEX_URL?.trim()749 || process.env.NEXT_PUBLIC_CONVEX_URL?.trim()
750 || "";750 || "";
751 const ingestToken =751 const ingestToken =
752 process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim()752 process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || "";
753 || process.env.OPENAPI_PARSE_API_TOKEN?.trim()
754 || "";
755 753
756 const shouldIngest =754 const shouldIngest =
757 workspaceId.length > 0755 workspaceId.length > 0
@@ -759,6 +757,14 @@ const parseEndpoint = async (request: any, response: any) => { · 1 unmodified line
759 && sourceName.length > 0757 && sourceName.length > 0
760 && convexUrl.length > 0;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 if (shouldIngest) {768 if (shouldIngest) {
763 const ingested = await toIngestPayload(openApiSpec, {769 const ingested = await toIngestPayload(openApiSpec, {
764 convexUrl,770 convexUrl,
apps/convex/convex/control_plane/tool_registry.ts+7−4
@@ -423,10 +423,13 @@ export const ingestOpenApiManifest = action({ · 422 unmodified lines
423 },423 },
424 handler: async (ctx, args) => {424 handler: async (ctx, args) => {
425 const expectedToken =425 const expectedToken =
426 process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim()426 process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || null;
427 || process.env.OPENAPI_PARSE_API_TOKEN?.trim()427
428 || "";428 if (!expectedToken) {
429 if (expectedToken.length > 0 && args.token.trim() !== expectedToken) {429 throw new Error("OpenAPI ingest token is not configured");
430 }
431
432 if (args.token.trim() !== expectedToken) {
430 throw new Error("Unauthorized OpenAPI ingest request");433 throw new Error("Unauthorized OpenAPI ingest request");
431 }434 }
432 435
apps/convex/convex/credential_resolver.ts+8−6
@@ -18,10 +18,12 @@ import { v } from "convex/values"; · 17 unmodified lines
18import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
19import * as Schema from "effect/Schema";19import * as Schema from "effect/Schema";
20 20
21import { api } from "./_generated/api";21import { internal } from "./_generated/api";
22import { query, type ActionCtx } from "./_generated/server";22import { internalQuery, type ActionCtx } from "./_generated/server";
23import { decryptSecretValue } from "./credential_crypto";23import { decryptSecretValue } from "./credential_crypto";
24 24
25const runtimeInternal = internal as any;
26
25const decodeSourceCredentialBinding = Schema.decodeUnknownSync(27const decodeSourceCredentialBinding = Schema.decodeUnknownSync(
26 SourceCredentialBindingSchema,28 SourceCredentialBindingSchema,
27);29);
@@ -34,7 +36,7 @@ const stripConvexSystemFields = ( · 6 unmodified lines
34 return rest;36 return rest;
35};37};
36 38
37export const listCredentialBindingsForSource = query({39export const listCredentialBindingsForSource = internalQuery({
38 args: {40 args: {
39 workspaceId: v.string(),41 workspaceId: v.string(),
40 sourceKey: v.string(),42 sourceKey: v.string(),
@@ -69,7 +71,7 @@ export const listCredentialBindingsForSource = query({ · 28 unmodified lines
69 },71 },
70});72});
71 73
72export const listOAuthTokensForSource = query({74export const listOAuthTokensForSource = internalQuery({
73 args: {75 args: {
74 workspaceId: v.string(),76 workspaceId: v.string(),
75 sourceId: v.string(),77 sourceId: v.string(),
@@ -140,7 +142,7 @@ export const createConvexResolveToolCredentials = ( · 64 unmodified lines
140 const bindings = yield* runQueryEffect(142 const bindings = yield* runQueryEffect(
141 "list_credential_bindings",143 "list_credential_bindings",
142 () =>144 () =>
143 ctx.runQuery(api.credential_resolver.listCredentialBindingsForSource, {145 ctx.runQuery(runtimeInternal.credential_resolver.listCredentialBindingsForSource, {
144 workspaceId: context.workspaceId,146 workspaceId: context.workspaceId,
145 sourceKey: context.sourceKey,147 sourceKey: context.sourceKey,
146 organizationId: context.organizationId ?? undefined,148 organizationId: context.organizationId ?? undefined,
@@ -161,7 +163,7 @@ export const createConvexResolveToolCredentials = ( · 14 unmodified lines
161 binding.provider === "oauth2" && sourceId163 binding.provider === "oauth2" && sourceId
162 ? yield* runQueryEffect("list_oauth_tokens", () =>164 ? yield* runQueryEffect("list_oauth_tokens", () =>
163 ctx165 ctx
164 .runQuery(api.credential_resolver.listOAuthTokensForSource, {166 .runQuery(runtimeInternal.credential_resolver.listOAuthTokensForSource, {
165 workspaceId: context.workspaceId,167 workspaceId: context.workspaceId,
166 sourceId,168 sourceId,
167 })169 })
apps/convex/convex/http_security.ts+69−0
@@ -0,0 +1,69 @@
1type RequestDenyOptions = {
2 status: number;
3 message: string;
4};
5
6type InternalSecretOptions = {
7 context: string;
8 envVarNames: ReadonlyArray<string>;
9 headerName?: string;
10};
11
12const trim = (value: string | undefined): string | undefined => {
13 const candidate = value?.trim();
14 return candidate && candidate.length > 0 ? candidate : undefined;
15};
16
17const denyRequest = (options: RequestDenyOptions): Response =>
18 Response.json(
19 {
20 error: options.message,
21 },
22 { status: options.status },
23 );
24
25const readEnvValue = (name: string): string | undefined => trim(process.env[name]);
26
27const 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
38const readRequestHeader = (request: Request, name: string): string | null =>
39 trim(request.headers.get(name) ?? undefined) ?? null;
40
41export 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
21 return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";21 return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
22};22};
23 23
24const mcpAuthRequired = (): boolean => {24const parseOptionalBooleanEnv = (
25 const deploymentMode = (process.env.EXECUTOR_DEPLOYMENT_MODE ?? "").trim().toLowerCase();25 value: string | undefined,
26 if (26): boolean | null => {
27 deploymentMode === "cloud"27 const normalized = value?.trim().toLowerCase();
28 || deploymentMode === "hosted"28 if (!normalized) {
29 || deploymentMode === "production"29 return null;
30 || deploymentMode === "prod"30 }
31 ) {31
32 if (["1", "true", "yes", "on"].includes(normalized)) {
32 return true;33 return true;
33 }34 }
34 35
35 if (36 if (["0", "false", "no", "off"].includes(normalized)) {
36 deploymentMode === "self-hosted"
37 || deploymentMode === "self_hosted"
38 || deploymentMode === "selfhosted"
39 ) {
40 return false;37 return false;
41 }38 }
42 39
43 return isTruthyEnvValue(process.env.EXECUTOR_ENFORCE_MCP_AUTH);40 return null;
41};
42
43const 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
46const getMcpAuthorizationServer = (): string | null =>59const getMcpAuthorizationServer = (): string | null =>
apps/convex/convex/runtimeCallbacks.ts+21−28
@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; · 12 unmodified lines
13 13
14import { internal } from "./_generated/api";14import { internal } from "./_generated/api";
15import { httpAction, type ActionCtx } from "./_generated/server";15import { httpAction, type ActionCtx } from "./_generated/server";
16import { enforceInternalSecret } from "./http_security";
16import { createConvexSourceToolRegistry } from "./source_tool_registry";17import { createConvexSourceToolRegistry } from "./source_tool_registry";
17 18
18class RuntimeToolCallBadRequestError extends Data.TaggedError(19class RuntimeToolCallBadRequestError extends Data.TaggedError(
@@ -44,13 +45,6 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 25 unmodified lines
44 45
45const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);46const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
46 47
47const readConfiguredWorkspaceId = (value: string | undefined): string => {
48 const normalized = value?.trim();
49 return normalized && normalized.length > 0 ? normalized : "ws_local";
50};
51
52const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
53
54const badRequest = (message: string): Response =>48const badRequest = (message: string): Response =>
55 Response.json(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
94const 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
99const resolveWorkspaceIdForToolCall = (88const resolveWorkspaceIdForToolCall = (
100 ctx: ActionCtx,89 ctx: ActionCtx,
101 request: Request,
102 input: RuntimeToolCallRequest,90 input: RuntimeToolCallRequest,
103): Effect.Effect<string, never> =>91): Effect.Effect<string, RuntimeToolCallBadRequestError> =>
104 Effect.gen(function* () {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 const runWorkspaceId = yield* Effect.tryPromise({93 const runWorkspaceId = yield* Effect.tryPromise({
116 try: () =>94 try: () =>
117 ctx.runQuery(internal.task_runs.getTaskRunWorkspaceId, {95 ctx.runQuery(internal.task_runs.getTaskRunWorkspaceId, {
118 runId: input.runId,96 runId: input.runId,
119 }),97 }),
120 catch: () => null,98 catch: (cause) =>
121 }).pipe(Effect.orElseSucceed(() => null));99 new RuntimeToolCallBadRequestError({
100 message: "Unable to resolve runtime callback workspace",
101 details: formatUnknownDetails(cause),
102 }),
103 });
122 104
123 if (typeof runWorkspaceId === "string" && runWorkspaceId.trim().length > 0) {105 if (typeof runWorkspaceId === "string" && runWorkspaceId.trim().length > 0) {
124 return runWorkspaceId.trim();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
130const handleToolCallHttpEffect = (115const handleToolCallHttpEffect = (
@@ -132,8 +117,16 @@ const handleToolCallHttpEffect = ( · 1 unmodified line
132 request: Request,117 request: Request,
133): Effect.Effect<Response, never> =>118): Effect.Effect<Response, never> =>
134 Effect.gen(function* () {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 const input = yield* decodeToolCallRequest(request);128 const input = yield* decodeToolCallRequest(request);
136 const workspaceId = yield* resolveWorkspaceIdForToolCall(ctx, request, input);129 const workspaceId = yield* resolveWorkspaceIdForToolCall(ctx, input);
137 130
138 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);131 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
139 132
apps/convex/convex/runtime_execution_port.ts+1−1
@@ -47,7 +47,7 @@ export const createExecuteRuntimeRunInConvex = ( · 46 unmodified lines
47 operation: "runtime_available",47 operation: "runtime_available",
48 message: `Runtime '${convexRuntimeAdapter.kind}' is not configured in this convex process.`,48 message: `Runtime '${convexRuntimeAdapter.kind}' is not configured in this convex process.`,
49 details: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
apps/pm/src/tool-call-handler.ts+46−0
@@ -36,6 +36,47 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 35 unmodified lines
36 36
37const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);37const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
38 38
39const trim = (value: string | undefined): string | undefined => {
40 const candidate = value?.trim();
41 return candidate && candidate.length > 0 ? candidate : undefined;
42};
43
44const readHeader = (request: Request, name: string): string | null =>
45 trim(request.headers.get(name) ?? undefined) ?? null;
46
47const readEnv = (name: string): string | undefined => trim(process.env[name]);
48
49const runtimeCallbackSecret = (): string | null =>
50 readEnv("CLOUDFLARE_SANDBOX_CALLBACK_SECRET") ?? null;
51
52const 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
62const 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
39const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError =>80const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError =>
40 new PmToolCallHttpRequestError({81 new PmToolCallHttpRequestError({
41 message: "Invalid runtime callback request body",82 message: "Invalid runtime callback request body",
@@ -79,6 +120,11 @@ export const createPmToolCallHttpHandler = ( · 37 unmodified lines
79): ((request: Request) => Promise<Response>) => {120): ((request: Request) => Promise<Response>) => {
80 return async (request: Request) => {121 return async (request: Request) => {
81 try {122 try {
123 const blocked = enforceToolCallSecurity(request);
124 if (blocked) {
125 return blocked;
126 }
127
82 const input = await decodeToolCallRequest(request);128 const input = await decodeToolCallRequest(request);
83 const result = await handleToolCall(input);129 const result = await handleToolCall(input);
84 return Response.json(result, { status: 200 });130 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
21 runUrl: string;21 runUrl: string;
22 authToken: string;22 authToken: string;
23 callbackUrl: string;23 callbackUrl: string;
24 callbackInternalSecret: string | null;24 callbackInternalSecret: string;
25 requestTimeoutMs: number;25 requestTimeoutMs: number;
26};26};
27 27
@@ -126,10 +126,7 @@ const resolveConfig = ( · 98 unmodified lines
126 );126 );
127 const callbackInternalSecret =127 const callbackInternalSecret =
128 options.callbackInternalSecret ??128 options.callbackInternalSecret ??
129 normalizeString(129 normalizeString(env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET) ??
130 env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET ??
131 env.EXECUTOR_INTERNAL_TOKEN,
132 ) ??
133 null;130 null;
134 131
135 const requestTimeoutMs =132 const requestTimeoutMs =
@@ -139,7 +136,7 @@ const resolveConfig = ( · 3 unmodified lines
139 defaultRequestTimeoutMs,136 defaultRequestTimeoutMs,
140 );137 );
141 138
142 if (!runUrl || !authToken || !callbackUrl) {139 if (!runUrl || !authToken || !callbackUrl || !callbackInternalSecret) {
143 return null;140 return null;
144 }141 }
145 142
@@ -163,7 +160,7 @@ const makeDispatchRequest = ( · 17 unmodified lines
163 timeoutMs: input.timeoutMs,160 timeoutMs: input.timeoutMs,
164 callback: {161 callback: {
165 url: config.callbackUrl,162 url: config.callbackUrl,
166 internalSecret: config.callbackInternalSecret ?? undefined,163 internalSecret: config.callbackInternalSecret,
167 },164 },
168 };165 };
169 166