diff --git a/apps/api/src/routes/oauth-discovery.http.test.ts b/apps/api/src/routes/oauth-discovery.http.test.ts index a1dc815aeb..ddaad60c89 100644 --- a/apps/api/src/routes/oauth-discovery.http.test.ts +++ b/apps/api/src/routes/oauth-discovery.http.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto" import { afterEach, describe, expect, it } from "@effect/vitest" import { OrgId, RoleName, UserId } from "@maple/domain/http" -import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema, String as Str } from "effect" import { HttpRouter } from "effect/http" import { Env } from "@maple/backend/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/testing/test-pglite" @@ -184,6 +184,33 @@ describe("MCP OAuth HTTP routes", () => { } }) + it("refuses a registration or token body past the size these requests ever need", async () => { + const harness = makeHarness() + try { + const padding = Str.repeat(70 * 1024)("x") + const registered = await harness.handler( + postJson("https://api.example.com/register", { + client_name: padding, + redirect_uris: ["http://127.0.0.1:49876/callback"], + }), + Context.empty(), + ) + expect(registered.status).toBe(400) + expect(await registered.json()).toEqual({ + error: "invalid_request", + error_description: "Request body is too large", + }) + const token = await harness.handler( + postForm("https://api.example.com/oauth/token", { grant_type: padding }), + Context.empty(), + ) + expect(token.status).toBe(400) + expect((await token.json()).error_description).toBe("Request body is too large") + } finally { + await harness.dispose() + } + }) + it("supports DCR with an ephemeral loopback port and a form-encoded PKCE token exchange", async () => { const harness = makeHarness() try { diff --git a/apps/api/src/routes/oauth-discovery.http.ts b/apps/api/src/routes/oauth-discovery.http.ts index 14bf103112..e49262c90a 100644 --- a/apps/api/src/routes/oauth-discovery.http.ts +++ b/apps/api/src/routes/oauth-discovery.http.ts @@ -1,5 +1,6 @@ import { Array as Arr, Effect, Schema, String as Str } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" +import { readBoundedText } from "@maple/backend/http/bounded-body" import { Env } from "@maple/backend/platform/Env" import { MCP_OAUTH_SCOPE, @@ -62,34 +63,42 @@ const protectedResourceMetadata = (origin: string) => ({ resource_documentation: "https://maple.dev/docs/reference/mcp", }) -const toWebRequest = Effect.fn("McpOAuthRouter.toWebRequest")(function* () { +/** A registration or token request is a few hundred bytes, and these routes read it before any credential. */ +const OAUTH_MAX_BODY_BYTES = 64 * 1024 + +const readBody = Effect.fn("McpOAuthRouter.readBody")(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* HttpServerRequest.toWeb(request).pipe( + return yield* readBoundedText(request, OAUTH_MAX_BODY_BYTES).pipe( Effect.mapError( - () => new McpOAuthProtocolError({ error: "invalid_request", message: "Unable to read request" }), + (error) => + new McpOAuthProtocolError({ + error: "invalid_request", + message: + error._tag === "@maple/backend/http/RequestBodyTooLargeError" + ? "Request body is too large" + : "Unable to read request body", + }), ), ) }) +const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)) + const readJson = Effect.fn("McpOAuthRouter.readJson")(function* () { - const request = yield* toWebRequest() - return yield* Effect.tryPromise({ - try: () => request.json(), - catch: () => - new McpOAuthProtocolError({ - error: "invalid_request", - message: "Request body must be valid JSON", - }), - }) + const text = yield* readBody() + return yield* decodeJsonText(text).pipe( + Effect.mapError( + () => + new McpOAuthProtocolError({ + error: "invalid_request", + message: "Request body must be valid JSON", + }), + ), + ) }) const readForm = Effect.fn("McpOAuthRouter.readForm")(function* () { - const request = yield* toWebRequest() - const text = yield* Effect.tryPromise({ - try: () => request.text(), - catch: () => - new McpOAuthProtocolError({ error: "invalid_request", message: "Unable to read form body" }), - }) + const text = yield* readBody() return new URLSearchParams(text) }) diff --git a/apps/api/src/routes/webhooks/planetscale.http.test.ts b/apps/api/src/routes/webhooks/planetscale.http.test.ts index 02130fb856..526e06c36c 100644 --- a/apps/api/src/routes/webhooks/planetscale.http.test.ts +++ b/apps/api/src/routes/webhooks/planetscale.http.test.ts @@ -153,6 +153,26 @@ describe("PlanetScaleWebhookRouter", () => { ) assert.strictEqual(rejected.status, 401) + // An unsigned delivery is refused whatever it carries, and an oversized one is never parsed. + const unsigned = yield* Effect.promise(() => + handler( + new Request(`http://api.localhost${WEBHOOK_PATH}`, { method: "POST", body: BODY }), + handlerContext, + ), + ) + assert.strictEqual(unsigned.status, 401) + const oversized = yield* Effect.promise(() => + handler( + new Request(`http://api.localhost${WEBHOOK_PATH}`, { + method: "POST", + headers: { "x-planetscale-signature": "invalid" }, + body: Str.repeat(1024 * 1024 + 1)("x"), + }), + handlerContext, + ), + ) + assert.strictEqual(oversized.status, 413) + // A database Maple registered no webhook on has no secret a signature could match. const otherDatabase = JSON.stringify({ event: "webhook.test", diff --git a/apps/api/src/routes/webhooks/planetscale.http.ts b/apps/api/src/routes/webhooks/planetscale.http.ts index 93b15f3a70..3c8d0461ab 100644 --- a/apps/api/src/routes/webhooks/planetscale.http.ts +++ b/apps/api/src/routes/webhooks/planetscale.http.ts @@ -1,6 +1,7 @@ import { Headers, HttpRouter, HttpServerResponse, type HttpServerRequest } from "effect/http" import { OrgId } from "@maple/domain/http" -import { Clock, Effect, Option, Schema } from "effect" +import { Clock, Effect, Option, Result, Schema } from "effect" +import { readBoundedText } from "@maple/backend/http/bounded-body" import { PlanetScaleConnectionService } from "@maple/backend/services/integrations/PlanetScaleConnectionService" import { PlanetScaleWebhookService } from "@maple/backend/services/integrations/PlanetScaleWebhookService" import { @@ -26,6 +27,9 @@ import { const ROUTE = "/api/integrations/planetscale/webhook/:connectionId" +/** A delivery is a few kilobytes; this is read before the signature can be checked. */ +const PLANETSCALE_WEBHOOK_MAX_BYTES = 1024 * 1024 + const textResponse = (body: string, status: number) => HttpServerResponse.text(body, { status }) const decodeOrgIdSync = Schema.decodeUnknownSync(OrgId) @@ -88,11 +92,21 @@ export const PlanetScaleWebhookRouter = HttpRouter.use((router) => yield* Effect.annotateCurrentSpan({ orgId: connection.orgId }) const orgId = decodeOrgIdSync(connection.orgId) - const bodyOpt = yield* req.text.pipe(Effect.option) - if (Option.isNone(bodyOpt) || bodyOpt.value.length === 0) { + // An unsigned delivery is refused before its body is read. + const signature = Option.getOrUndefined(Headers.get(req.headers, "x-planetscale-signature")) + if (signature === undefined) { + return yield* reject(401, "signature_rejected", "Invalid signature") + } + const read = yield* Effect.result(readBoundedText(req, PLANETSCALE_WEBHOOK_MAX_BYTES)) + if (Result.isFailure(read)) { + return read.failure._tag === "@maple/backend/http/RequestBodyTooLargeError" + ? yield* reject(413, "body_too_large", "Request body too large") + : yield* reject(400, "unreadable_body", "Unreadable request body") + } + const rawBody = read.success + if (rawBody.length === 0) { return yield* reject(400, "empty_body", "Missing request body") } - const rawBody = bodyOpt.value // Each database's webhook has its own secret. Only the database name is read before // the signature is checked, to pick the secret it must match. @@ -100,7 +114,6 @@ export const PlanetScaleWebhookRouter = HttpRouter.use((router) => const secret = Option.isNone(target) ? null : yield* webhooks.secretFor(orgId, target.value.database ?? "") - const signature = Option.getOrUndefined(Headers.get(req.headers, "x-planetscale-signature")) if (secret === null || !verifyPlanetScaleSignature(rawBody, secret, signature)) { return yield* reject(401, "signature_rejected", "Invalid signature") } diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 156fb7f0fe..760fb357c6 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -125,12 +125,15 @@ const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", Htt // script out of the deployed bundle (guards the 3 MB worker size limit, error // 10027). The reference pages depend on jsDelivr being reachable from the browser. // `/docs` and `/v2/docs` serve the same public v2 reference. +// Pinned: an unpinned tag would run whatever the package publishes next on this origin. +const SCALAR_VERSION = "1.76.4" const apiReferenceRoute = (path: `/${string}`) => Layer.unwrap( Effect.gen(function* () { const env = yield* Env return HttpApiScalar.layerCdn(instanceApiV2(Str.replace(/\/+$/, "")(env.MAPLE_API_BASE_URL)), { path, + version: SCALAR_VERSION, }) }), ) diff --git a/apps/web/src/api-proxy.ts b/apps/web/src/api-proxy.ts index 1969576b1e..41ecc5449f 100644 --- a/apps/web/src/api-proxy.ts +++ b/apps/web/src/api-proxy.ts @@ -1,10 +1,22 @@ -import { String as Str } from "effect" +import { Option, String as Str } from "effect" /** Same-origin path to the API, so browser calls skip the CORS preflight. */ export const API_PROXY_PREFIX = "/_api" +/** The API's reference pages run a third-party viewer script, so they stay on the API's own origin. */ +const NOT_PROXIED = new Set(["/docs", "/v2/docs"]) + +const decodePath = Option.liftThrowable(decodeURI) + +/** The path as the API's router matches it: anything from a `;` dropped, escapes decoded, no trailing slash. */ +const routedPath = (path: string): string => { + const raw = Str.replace(/;.*$/, "")(path) + // A malformed escape decodes to nothing the router could match either. + return Str.replace(/\/+$/, "")(Option.getOrElse(decodePath(raw), () => raw)) +} + export const apiProxyPath = (pathname: string): string | undefined => { if (pathname === API_PROXY_PREFIX) return "/" - return Str.startsWith(`${API_PROXY_PREFIX}/`)(pathname) - ? Str.slice(API_PROXY_PREFIX.length)(pathname) - : undefined + if (!Str.startsWith(`${API_PROXY_PREFIX}/`)(pathname)) return undefined + const path = Str.slice(API_PROXY_PREFIX.length)(pathname) + return NOT_PROXIED.has(routedPath(path)) ? undefined : path } diff --git a/apps/web/src/handler.test.ts b/apps/web/src/handler.test.ts index cb493ed8a4..a27ad8cd84 100644 --- a/apps/web/src/handler.test.ts +++ b/apps/web/src/handler.test.ts @@ -33,6 +33,17 @@ describe("apiProxyPath", () => { expect(apiProxyPath("/_apikeys")).toBeUndefined() expect(apiProxyPath("/api/chat")).toBeUndefined() }) + + it("leaves the API's reference pages on the API's own origin", () => { + expect(apiProxyPath("/_api/docs")).toBeUndefined() + expect(apiProxyPath("/_api/v2/docs/")).toBeUndefined() + expect(apiProxyPath("/_api/v2/docs-like")).toBe("/v2/docs-like") + // However the path is spelled, if the API's router would read it as a reference page. + expect(apiProxyPath("/_api/%64ocs")).toBeUndefined() + expect(apiProxyPath("/_api/v2/doc%73")).toBeUndefined() + expect(apiProxyPath("/_api/docs;x")).toBeUndefined() + expect(apiProxyPath("/_api/v2/traces%2Fdocs")).toBe("/v2/traces%2Fdocs") + }) }) describe("handleRequest API proxy", () => { diff --git a/packages/backend/src/services/auth/CliDeviceAuthService.ts b/packages/backend/src/services/auth/CliDeviceAuthService.ts index 201858aa71..c4ef099d75 100644 --- a/packages/backend/src/services/auth/CliDeviceAuthService.ts +++ b/packages/backend/src/services/auth/CliDeviceAuthService.ts @@ -54,10 +54,9 @@ const makeUserCode = () => { return value } -const persistenceError = (error: unknown) => - new CliDevicePersistenceError({ - message: error instanceof Error ? error.message : "CLI device authorization persistence failed", - }) +// These errors answer unauthenticated callers; `makeDbExecute` has already logged the driver's message. +const persistenceError = () => + new CliDevicePersistenceError({ message: "CLI device authorization is temporarily unavailable" }) type ApprovalIdentity = { readonly orgId: OrgId diff --git a/packages/backend/src/services/auth/McpOAuthService.ts b/packages/backend/src/services/auth/McpOAuthService.ts index f354893d3e..7c5c41e48a 100644 --- a/packages/backend/src/services/auth/McpOAuthService.ts +++ b/packages/backend/src/services/auth/McpOAuthService.ts @@ -117,10 +117,9 @@ const hashOpaqueToken = (value: string) => createHash("sha256").update(value).di const makeOpaqueToken = (prefix: string) => `${prefix}${randomBytes(32).toString("base64url")}` const decodeApiKeyId = Schema.decodeUnknownSync(ApiKeyId) -const persistenceError = (error: unknown) => - new McpOAuthPersistenceError({ - message: error instanceof Error ? error.message : "MCP OAuth persistence failed", - }) +// These errors answer unauthenticated callers; `makeDbExecute` has already logged the driver's message. +const persistenceError = () => + new McpOAuthPersistenceError({ message: "MCP authorization is temporarily unavailable" }) const protocolError = ( error: string, diff --git a/packages/backend/src/services/support/SupportChannelService.test.ts b/packages/backend/src/services/support/SupportChannelService.test.ts index 1f6edc5ce3..7ec2c3a582 100644 --- a/packages/backend/src/services/support/SupportChannelService.test.ts +++ b/packages/backend/src/services/support/SupportChannelService.test.ts @@ -32,6 +32,7 @@ interface World { /** Runs while Slack is "creating", to stage a concurrent takeover. */ duringCreate: (() => Promise) | null takenNames: Set + orgName: string calls: Array<{ method: SupportSlackMethod; body: Record }> } @@ -41,6 +42,7 @@ const freshWorld = (): World => ({ duringCreate: null, botChannels: [], takenNames: new Set(), + orgName: "Acme Inc.", calls: [], }) @@ -107,7 +109,7 @@ const stubs = (world: World) => retrieve: (orgId) => Effect.succeed({ id: orgId, - name: "Acme Inc.", + name: world.orgName, slug: "acme", imageUrl: null, createdAtMs: 0, @@ -178,6 +180,23 @@ describe("SupportChannelService", () => { }).pipe(Effect.provide(makeLayer(world, testDb))) }) + it.effect("posts an org name as text, never as a mention or a link", () => { + const world = freshWorld() + world.orgName = " & " + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* invite(tenant) + const welcome = Arr.findFirst(world.calls, (call) => call.method === "chat.postMessage") + assert.isTrue(Option.isSome(welcome)) + if (Option.isSome(welcome)) { + assert.include( + String(welcome.value.body.text), + "This is <!channel> & <https://example.test|Acme>'s shared channel", + ) + } + }).pipe(Effect.provide(makeLayer(world, testDb))) + }) + it.effect("keeps the channel it created when the invite that follows fails", () => { const world = freshWorld() world.inviteFails = true diff --git a/packages/backend/src/services/support/SupportChannelService.ts b/packages/backend/src/services/support/SupportChannelService.ts index d9ae318130..9d41108b0e 100644 --- a/packages/backend/src/services/support/SupportChannelService.ts +++ b/packages/backend/src/services/support/SupportChannelService.ts @@ -26,6 +26,10 @@ import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService import { OrganizationService } from "@maple/backend/services/org/OrganizationService" import { SupportSlackClient } from "./SupportSlackClient" +/** Slack reads `<…>` as a mention or link and `&` as an entity, so a customer's text is escaped. */ +export const escapeSlackText = (text: string): string => + Str.replaceAll(">", ">")(Str.replaceAll("<", "<")(Str.replaceAll("&", "&")(text))) + /** * `maple-` in Slack's channel-name alphabet: lowercase letters, digits, `-` and `_`, at most * 80 characters. Falls back to the org id when the name has nothing usable in it. @@ -341,7 +345,7 @@ const make = Effect.gen(function* () { yield* slack .call("chat.postMessage", { channel: channelId, - text: `This is ${orgName ?? "your team"}'s shared channel with the Maple team. Ask us anything here: setup, bugs, billing or feature requests.`, + text: `This is ${orgName === null ? "your team" : escapeSlackText(orgName)}'s shared channel with the Maple team. Ask us anything here: setup, bugs, billing or feature requests.`, }) .pipe( Effect.tapError((error) =>