Skip to content

fix(api): sanitize failures from the OpenAPI spec route - #852

Merged
juliopolycarpo merged 2 commits into
mainfrom
fix/openapi-spec-error-leak
Aug 14, 2026
Merged

juliopolycarpo merged 2 commits into
mainfrom
fix/openapi-spec-error-leak

Conversation

@juliopolycarpo

@juliopolycarpo juliopolycarpo commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Sanitize generation failures on GET /scalar/json and its trailing-slash alias. errorHandler never classified that route, so Elysia's built-in renderer published the raw exception message as detail on an unauthenticated endpoint.
  • A successful document is still amended with ProblemDetails. A non-GET miss on the same path stays a 404 from errorHandler, not an INTERNAL rewrite.
  • Closes OpenAPI spec route leaks raw exception messages past the error handler #851.

Changes

  • Seat a global error arm on openapiProblemDetails (already mounted before openapi() in app.ts) so the spec route answers first. @elysia/openapi's local error hook logs and returns nothing, which is what used to hand the failure to Elysia's renderer.
  • Treat /scalar/json and /scalar/json/ as the same document path. path keeps the request spelling, so an exact match left the alias unhooked for both sanitizing and amending.
  • Let NotFound fall through. A path-only filter was rewriting POST /scalar/json from 404 into INTERNAL.
  • Amend only a successful document, gating on status rather than error shape, so a sanitized plain-object body is not merged with components.schemas. Spec failures go through the negotiator (Vary: Accept) because errorHandler's copy cannot reach this route.
  • Log the raw error server-side under [openapi-spec]. That is the only record of what failed.
  • Add apps/api/tests/unit/server/openapi-spec-error.test.ts for the leak (including the alias, negotiation, POST 404, and a pin with the guard removed). Integration covers POST 404 and trailing-slash amendment on the real app.

Test Plan

  • bun run check passes
  • bun run test passes
  • bun run build passes
  • New UI strings use @mangostudio/shared/i18n (no hardcoded strings)
  • Manually smoke-tested: login, chat creation, image generation

CI on this head: Check / Check (tooling + typecheck), Test / Unit & Integration Tests, and Build / Build (frontend + API) all passed. No frontend or i18n changes. Login/chat/image smoke is not the regression path; the leak and alias are covered by the unit suite plus the two new integration cases.

Screenshots / GIFs

N/A.

Notes

No env, schema, or breaking API changes.

Moving errorHandler to the front of the outer app is not an option. The frontend NotFound arm is seated ahead of it on purpose. Elysia walks NotFound handlers in registration order and stops at the first that returns something.

Whether /scalar should be exposed unauthenticated in production is a deployment question, not this bug.

`GET /scalar/json` was the one route `errorHandler` never classified, so a
failed document generation answered with the raw exception message as
`detail` on an unauthenticated endpoint.

Two conditions had to line up. `openapi()` is registered on the outer app
before `.use(api)`, while `errorHandler` lives inside the `api` instance —
and a `global` hook only reaches routes declared after it. `@elysia/openapi`
then puts a local `error` hook on the spec route that logs and returns
nothing, consuming the failure before the later-registered arms see it, so
Elysia falls back to its built-in renderer. Neither alone leaks; only the
combination does.

Seat the guard next to the document amendment instead, which already has to
precede the plugin for the same reach reason. Moving `errorHandler` forward
was not an option: the frontend `NotFound` arm is deliberately seated ahead
of it, and Elysia stops at the first handler that returns something.

Two adjacent fixes fall out of it:

- The amendment hook gated only on "path is the spec path" and "plain object",
  so the sanitized error body — a plain object served from that path — would
  have come back merged with `components.schemas`. It now amends only a
  successful document, gating on the status rather than on the error shape so
  it stays uncoupled from the error contract.
- A spec failure reaches the negotiator, so it answers `problem+json` with
  `Vary: Accept` like every other failure rather than being the one endpoint
  whose representation is decided by hook order.

Covered by four cases in the API error suite, including one pinning the leak
with the guard removed so the precondition is visible if the plugin changes.

Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
@juliopolycarpo juliopolycarpo added area: api Elysia routes, API modules, middleware, or backend services. type: hardening Defensive work closing a class of bugs or attack surface. labels Aug 14, 2026
@github-actions github-actions Bot added type: test Test coverage, test infrastructure, or test reliability work. area: cli CLI entry points, command handlers, launcher, or detach/runtime processes. labels Aug 14, 2026
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ce4e1b1e-119c-438f-b0a6-3117883439c4

📥 Commits

Reviewing files that changed from the base of the PR and between 1e59ba9 and 86f4af0.

📒 Files selected for processing (3)
  • apps/api/src/app.ts
  • apps/api/src/server/openapi-problem-details.ts
  • apps/api/tests/integration/routes/problem-details.integration.test.ts

Summary by CodeRabbit

  • Bug Fixes
    • OpenAPI specification failures now return sanitized problem-detail responses instead of exposing raw exception messages.
    • Error responses correctly negotiate the application/problem+json format and preserve relevant response headers.
    • Successful OpenAPI documents continue to be served normally.
  • Tests
    • Added coverage for error sanitization, content negotiation, logging, header preservation, and prevention of sensitive message leakage.

Walkthrough

The OpenAPI problem-details middleware now detects failed specification responses and negotiates their error representation. A scoped error hook logs specification-generation failures and returns a generic 500 ApiErrorResponse without exposing exception details. Integration tests cover default and ProblemDetails responses, Vary: Accept, logging, message sanitization, and the unguarded failure case.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant OpenAPI spec route
  participant openapiProblemDetails
  participant Error response
  Client->>OpenAPI spec route: request specification
  OpenAPI spec route->>openapiProblemDetails: report generation failure
  openapiProblemDetails->>openapiProblemDetails: log original exception
  openapiProblemDetails->>Error response: create sanitized 500 response
  Error response-->>Client: negotiated error representation
Loading

Assessment against linked issues

Objective Addressed Explanation
Sanitize OpenAPI specification-generation failures and prevent raw exception details [#851] ✅
Return an ApiErrorResponse with status 500 and support negotiated problem-details responses [#851] ✅
Preserve Vary: Accept and log the original exception [#851] ✅

Possibly related PRs

Merge Risk: ⚪ Minimal · up to 86f4a

The change sanitizes failures from the OpenAPI specification route while preserving successful document responses and content negotiation; no actionable merge-blocking risk remains after normal checks and review.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 86f4af0f45

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/server/openapi-problem-details.ts Outdated
Comment thread apps/api/src/server/openapi-problem-details.ts Outdated
Comment thread apps/api/tests/integration/routes/problem-details.integration.test.ts Outdated
@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

PR head: cb22d27 • base: 1e59ba9 • CI run

Commits — 2 commits

Base 1e59ba9 → head cb22d27, oldest first.

  • 86f4af0 fix(api): sanitize failures from the OpenAPI spec route
  • cb22d27 fix(api): cover the spec route's trailing-slash alias
Full commit messages

86f4af0 fix(api): sanitize failures from the OpenAPI spec route

fix(api): sanitize failures from the OpenAPI spec route

`GET /scalar/json` was the one route `errorHandler` never classified, so a
failed document generation answered with the raw exception message as
`detail` on an unauthenticated endpoint.

Two conditions had to line up. `openapi()` is registered on the outer app
before `.use(api)`, while `errorHandler` lives inside the `api` instance —
and a `global` hook only reaches routes declared after it. `@elysia/openapi`
then puts a local `error` hook on the spec route that logs and returns
nothing, consuming the failure before the later-registered arms see it, so
Elysia falls back to its built-in renderer. Neither alone leaks; only the
combination does.

Seat the guard next to the document amendment instead, which already has to
precede the plugin for the same reach reason. Moving `errorHandler` forward
was not an option: the frontend `NotFound` arm is deliberately seated ahead
of it, and Elysia stops at the first handler that returns something.

Two adjacent fixes fall out of it:

- The amendment hook gated only on "path is the spec path" and "plain object",
  so the sanitized error body — a plain object served from that path — would
  have come back merged with `components.schemas`. It now amends only a
  successful document, gating on the status rather than on the error shape so
  it stays uncoupled from the error contract.
- A spec failure reaches the negotiator, so it answers `problem+json` with
  `Vary: Accept` like every other failure rather than being the one endpoint
  whose representation is decided by hook order.

Covered by four cases in the API error suite, including one pinning the leak
with the guard removed so the precondition is visible if the plugin changes.

Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>

cb22d27 fix(api): cover the spec route's trailing-slash alias

fix(api): cover the spec route's trailing-slash alias

Elysia serves GET /scalar/json/ as the same document, but path keeps the
trailing slash, so the exact match skipped both the sanitizer and the
amendment. A generation failure on that spelling still leaked the raw
exception.

The same path-only filter also rewrote POST /scalar/json from a 404 into
INTERNAL. NotFound now falls through to errorHandler.

The throwing-generator fixture moved to the unit suite: it never needed
the assembled app, and integration tests in this workspace go through
createApiTestApp or the real app.

Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>

📝 Changelog Preview

Entries this branch would add to the changelog on release:

[Unreleased]

🐛 Bug Fixes

  • (api) Cover the spec route's trailing-slash alias
  • (api) Sanitize failures from the OpenAPI spec route
CI duration (wall clock, critical path, slowest jobs)

CI Duration

Report-only timing from the Actions jobs API; runner variance does not affect the gate.
Comparisons cover the jobs both runs share, so main-only stages never skew the baseline.

Metric Base Head Δ
Wall clock (shared jobs) 8m 42s 9m 0s 🔴 ▲ +18s
Critical path Test / Unit & Integration Tests · 7m 1s Test / Unit & Integration Tests · 7m 0s 🟢 ▼ -1s

Since previous PR run: 8m 35s → 9m 0s (🔴 ▲ +25s)

Five slowest head jobs

Job Base Head Δ
Test / Unit & Integration Tests 7m 1s 7m 0s 🟢 ▼ -1s
Distribution / Build immutable distribution 3m 19s 2m 58s 🟢 ▼ -21s
QA Metrics / Collect 1m 31s 1m 44s 🔴 ▲ +13s
Smoke — Browser / Chromium smoke suite 1m 9s 54s 🟢 ▼ -15s
Smoke — Binary / Binary windows-arm64 51s 52s 🔴 ▲ +1s

QA Gate — Coverage & Quality

Base: 1e59ba9 • Head: 00e8221 • generated 2026-08-14T23:06:43.865Z

✅ No attention signals — collected metrics look healthy against base.

LoC (code): 🔴 ▲ +157 • Line coverage (all workspaces): ⚪ ▲ = 0 • Quick check: pass • Duplication: 🟢 ▼ -0pp • Bundle gzip: ⚪ ▲ = 0 • Locked deps: ⚪ ▲ = 0 • Tests passed: 🟢 ▲ +8

Metric details (coverage, LoC, bundle, dependencies, tests, duplication, tooling)

Coverage

API/shared/runtime branches and statements are source-derived from LCOV line hits because Bun LCOV does not emit branch or statement records.

Workspace Metric Base Head Δ
frontend lines 86.13% (13,489/15,661) 86.12% (13,487/15,661) 🔴 ▼ -0.01pp
frontend statements 75.99% (7,663/10,084) 75.95% (7,659/10,084) 🔴 ▼ -0.04pp
frontend functions 72.68% (2,368/3,258) 72.68% (2,368/3,258) ⚪ ▲ = 0
frontend branches 67.83% (5,507/8,119) 67.80% (5,505/8,119) 🔴 ▼ -0.03pp
api lines 81.94% (67,736/82,661) 81.95% (67,753/82,680) 🟢 ▲ +0.01pp
api statements 81.03% (27,039/33,370) 81.03% (27,055/33,388) ⚪ ▲ = 0
api functions 84.68% (6,803/8,034) 84.70% (6,807/8,037) 🟢 ▲ +0.02pp
api branches 49.37% (10,083/20,422) 49.37% (10,091/20,438) ⚪ ▲ = 0
shared lines 98.20% (14,220/14,480) 98.20% (14,220/14,480) ⚪ ▲ = 0
shared statements 95.00% (2,585/2,721) 95.00% (2,585/2,721) ⚪ ▲ = 0
shared functions 93.01% (426/458) 93.01% (426/458) ⚪ ▲ = 0
shared branches 61.08% (885/1,449) 61.08% (885/1,449) ⚪ ▲ = 0
runtime lines 81.34% (24,866/30,571) 81.34% (24,866/30,571) ⚪ ▲ = 0
runtime statements 77.65% (8,938/11,510) 77.65% (8,938/11,510) ⚪ ▲ = 0
runtime functions 76.45% (1,860/2,433) 76.45% (1,860/2,433) ⚪ ▲ = 0
runtime branches 48.52% (3,496/7,205) 48.52% (3,496/7,205) ⚪ ▲ = 0

Lines of Code

Workspace Base Head Δ
frontend 581 files / 66,686 lines 581 files / 66,686 lines files ⚪ ▲ = 0 • code ⚪ ▲ = 0
api 972 files / 138,438 lines 973 files / 138,595 lines files 🔴 ▲ +1 • code 🔴 ▲ +157
shared 197 files / 26,754 lines 197 files / 26,754 lines files ⚪ ▲ = 0 • code ⚪ ▲ = 0
runtime 824 files / 39,652 lines 824 files / 39,652 lines files ⚪ ▲ = 0 • code ⚪ ▲ = 0
total 2,574 files / 271,530 lines 2,575 files / 271,687 lines files 🔴 ▲ +1 • code 🔴 ▲ +157

Frontend Bundle

Metric Base Head Δ
gzip total 839.5 KiB 839.5 KiB ⚪ ▲ = 0
gzip JavaScript 823.6 KiB 823.6 KiB ⚪ ▲ = 0
gzip CSS 15.0 KiB 15.0 KiB ⚪ ▲ = 0
gzip HTML 948 B 948 B ⚪ ▲ = 0
tracked files 35 35 ⚪ ▲ = 0

Dependencies

Metric Base Head Δ
locked packages 859 859 ⚪ ▲ = 0
direct dependencies 43 43 ⚪ ▲ = 0
direct devDependencies 41 41 ⚪ ▲ = 0
workspace manifests 5 5 ⚪ ▲ = 0

Tests

Single full-suite pass (unit + integration, from the coverage run).

Base Head Δ passed
6,067 passed (root 6,067 / frontend 0 / api 0 / shared 0 / runtime 0) · exit 0 · 397s 6,075 passed (root 6,075 / frontend 0 / api 0 / shared 0 / runtime 0) · exit 0 · 394s 🟢 ▲ +8

Code Duplication (jscpd)

Metric Base Head Δ
clones 1,178 1,179 🔴 ▲ +1
duplicated lines 13,824 13,829 🔴 ▲ +5
percentage 4.11% 4.10% 🟢 ▼ -0pp

Repo Tooling

Metric Base Head Δ
Full repo check pass pass ⚪ ▲ = 0
TS errors (total) 0 0 ⚪ ▲ = 0
Circular dependencies 0 0 ⚪ ▲ = 0

Elysia serves GET /scalar/json/ as the same document, but path keeps the
trailing slash, so the exact match skipped both the sanitizer and the
amendment. A generation failure on that spelling still leaked the raw
exception.

The same path-only filter also rewrote POST /scalar/json from a 404 into
INTERNAL. NotFound now falls through to errorHandler.

The throwing-generator fixture moved to the unit suite: it never needed
the assembled app, and integration tests in this workspace go through
createApiTestApp or the real app.

Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
@juliopolycarpo
juliopolycarpo merged commit 7688cd2 into main Aug 14, 2026
37 checks passed
@juliopolycarpo
juliopolycarpo deleted the fix/openapi-spec-error-leak branch August 14, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: api Elysia routes, API modules, middleware, or backend services. area: cli CLI entry points, command handlers, launcher, or detach/runtime processes. type: hardening Defensive work closing a class of bugs or attack surface. type: test Test coverage, test infrastructure, or test reliability work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAPI spec route leaks raw exception messages past the error handler

1 participant