Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/api/src/routes/oauth-discovery.http.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
45 changes: 27 additions & 18 deletions apps/api/src/routes/oauth-discovery.http.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
})

Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/routes/webhooks/planetscale.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 18 additions & 5 deletions apps/api/src/routes/webhooks/planetscale.http.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -88,19 +92,28 @@ 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.
const target = yield* decodeWebhookDatabase(rawBody).pipe(Effect.option)
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")
}
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}),
)
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/api-proxy.ts
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions apps/web/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/backend/src/services/auth/CliDeviceAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions packages/backend/src/services/auth/McpOAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface World {
/** Runs while Slack is "creating", to stage a concurrent takeover. */
duringCreate: (() => Promise<void>) | null
takenNames: Set<string>
orgName: string
calls: Array<{ method: SupportSlackMethod; body: Record<string, unknown> }>
}

Expand All @@ -41,6 +42,7 @@ const freshWorld = (): World => ({
duringCreate: null,
botChannels: [],
takenNames: new Set(),
orgName: "Acme Inc.",
calls: [],
})

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = "<!channel> & <https://example.test|Acme>"
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 &lt;!channel&gt; &amp; &lt;https://example.test|Acme&gt;'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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(">", "&gt;")(Str.replaceAll("<", "&lt;")(Str.replaceAll("&", "&amp;")(text)))

/**
* `maple-<org>` 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.
Expand Down Expand Up @@ -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) =>
Expand Down
Loading