Skip to content

fix: return no origin instead of throwing for disallowed CORS origins - #134

Merged
thebentern merged 1 commit into
masterfrom
fix/cors-disallowed-origin-500
Sep 2, 2026
Merged

fix: return no origin instead of throwing for disallowed CORS origins#134
thebentern merged 1 commit into
masterfrom
fix/cors-disallowed-origin-500

Conversation

@jamesarich

@jamesarich jamesarich commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Why

A request from an origin outside the whitelist gets HTTP 500, not a CORS denial.

@tinyhttp/cors passes the origin() return value straight into res.setHeader and never wraps the call, so the throw new Error("Origin not allowed by CORS") at src/index.ts:56 escapes the middleware and becomes a 500 for the whole request, preflight included.

That is wrong in three ways:

  1. It reports a client-side policy decision as a server fault. A disallowed origin is a normal, expected outcome. It should not surface as a 5xx, page as an outage, or land in logs as one.
  2. It hides the real cause. The browser shows a generic 500 rather than a CORS error, and curl against the same endpoint looks perfectly healthy because a plain request sends no Origin at all. This cost me a while to pin down.
  3. Enforcement does not belong on the server here. Omitting the header and letting the browser block the read is exactly what the no-origin branch a few lines above already does.

Returning "" for a disallowed origin makes the two paths consistent. credentials: true is set, so a wildcard is not an option and none is proposed.

On why "" and not something more explicit, since origin() feeds res.setHeader directly (measured, same harness as below):

origin() returns result
"" 200, access-control-allow-origin: ""
false 200, access-control-allow-origin: false
null 200, access-control-allow-origin: null
undefined 500, ERR_HTTP_INVALID_HEADER_VALUE

false and null do deny correctly, they just put a junk token on the wire that reads like a bug to the next person. undefined swaps one 500 for another. "" is the value this service already emits for no-Origin requests, so it is the one with production evidence behind it.

Correction: the commit message on this branch says false "is not a valid header value". That is wrong, as the table shows. I could not amend it without a force-push; this section is the accurate version.

Nothing about which origins are allowed changes in this PR.

Testing Performed

Verified against @tinyhttp/cors 2.0.0 (the pinned version) with a local harness running the current and proposed origin() side by side:

case method current proposed
no Origin GET 200, acao: "" 200, acao: ""
allowed origin GET 200, acao: https://meshtastic.org 200, acao: https://meshtastic.org
disallowed origin GET 500 200, acao: ""
disallowed origin OPTIONS 500 204, acao: ""

The 500s match production today:

$ curl -o /dev/null -w '%{http_code}' -H 'Origin: https://client.meshtastic.org' \
    https://api.meshtastic.org/resource/deviceHardware
500                       # body: Origin not allowed by CORS

$ curl -o /dev/null -w '%{http_code}' -H 'Origin: https://meshtastic.org' ...
200                       # access-control-allow-origin: https://meshtastic.org

$ curl -o /dev/null -w '%{http_code}' -H 'Origin: http://localhost:3000' ...
200                       # access-control-allow-origin: http://localhost:3000

A plain request with no Origin already receives an empty access-control-allow-origin in production, so the proposed value is behaviour this service is known to serve correctly.

biome ci over src/ and scripts/ is unchanged: 0 errors, and the same 2 pre-existing warnings in src/lib/mqtt.ts and src/services/gateway.ts, neither touched here. I could not run pnpm install locally (4 @buf/* lockfile entries have no integrity field, which pnpm 11 rejects; CI's pnpm 9 does not enforce this), so pnpm build is unverified locally. The change is one return statement inside an existing string-returning function, so I would expect tsc to be indifferent, but worth a CI confirmation.

Two related findings, not fixed here

Both turned up while tracking down the above. Neither is actionable in this repo, flagging them rather than guessing at a fix.

  1. client.meshtastic.org is not on its own API's allowlist. It is live and serving, but gets the 500 above. map.meshtastic.org and flasher.meshtastic.org are on the list. Adding an origin is your call, not a bug fix, so I deliberately left the whitelist untouched. Happy to add it in a follow-up if you want it.

  2. apiv2 serves iconUrl values pointing at api.meshtastic.org. src/routes/eventFirmware.ts re-origins hosted icon URLs off X-Forwarded-Host, which is exactly right, but apiv2 responds with server: cloudflare and no x-powered-by: tinyhttp and ignores cache-busting query strings, so it is serving R2 objects without running this route. The result is that on apiv2 the event icon URLs still point at v1, where a browser request for them hits the 500 this PR fixes. That lives in the apiv2 Worker/R2 sync rather than here, so there is nothing to change in this repo, but you will want it on the list.

Context

Found while pointing the Meshtastic Android/desktop/web app at apiv2.meshtastic.org. The wasmJs web target cannot use v1 at all because of this 500; apiv2 serves access-control-allow-origin: * and a 204 preflight. Payload parity between the two hosts checked out across all six endpoints the app consumes plus the maintenance UF2 binaries (byte-identical, digests match).

/cc @thebentern

A request from an origin outside the whitelist gets HTTP 500, not a CORS
denial. @tinyhttp/cors passes the origin() return value straight to
res.setHeader and never wraps the call, so the thrown Error escapes the
middleware and becomes a 500 for the whole request, including the OPTIONS
preflight.

That is wrong in three ways. It reports a client-side policy decision as a
server fault, so it pages as an outage and reads like one in logs. It hides
the real cause: the browser shows a generic 500 rather than a CORS message,
and curl against the endpoint looks fine because a plain request sends no
Origin at all. And enforcement does not belong on the server here anyway;
omitting the header and letting the browser block the read is what the
no-origin branch a few lines above already does.

Returning "" for a disallowed origin makes the two paths consistent. It is
also the only workable value: origin() feeds res.setHeader directly, so
returning undefined throws ERR_HTTP_INVALID_HEADER_VALUE and returning false
is not a valid header value. Credentials are enabled, so a wildcard is not an
option and none is proposed. Nothing about which origins are allowed changes.

Verified against @tinyhttp/cors 2.0.0, the pinned version, with a local
harness running the current and proposed origin() side by side:

  current   no Origin          GET     -> 200 acao=""
            allowed Origin     GET     -> 200 acao="https://meshtastic.org"
            disallowed Origin  GET     -> 500 acao=undefined
            disallowed Origin  OPTIONS -> 500 acao=undefined

  proposed  no Origin          GET     -> 200 acao=""
            allowed Origin     GET     -> 200 acao="https://meshtastic.org"
            disallowed Origin  GET     -> 200 acao=""
            disallowed Origin  OPTIONS -> 204 acao=""

The 500s match production today: api.meshtastic.org returns 500 with body
"Origin not allowed by CORS" for https://client.meshtastic.org, and 200 with a
reflected header for https://meshtastic.org and http://localhost:3000. A
plain request with no Origin already receives an empty
access-control-allow-origin in production, so the proposed value is behaviour
this service is known to serve correctly.

biome ci over src/ and scripts/ is unchanged at 0 errors and the same 2
pre-existing warnings, in files this commit does not touch.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: b725f23f-43a6-4ae7-a0d6-79bfb07cae82

📥 Commits

Reviewing files that changed from the base of the PR and between d2c1773 and 70727c9.

📒 Files selected for processing (1)
  • src/index.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The CORS origin callback now returns an empty string for non-whitelisted origins instead of throwing an error.

Changes

CORS origin handling

Layer / File(s) Summary
Non-whitelisted origin denial
src/index.ts
The CORS callback returns an empty string for non-whitelisted origins. This matches the no-origin branch and prevents thrown errors from becoming 500 responses.

Estimated code review effort: 2 (Simple) | ~5 minutes

Merge Risk: 🔵 Low · up to 70727

This change prevents disallowed origins from producing erroneous 500 responses while keeping the existing allowlist unchanged. The PR is mergeable with owner awareness that state-changing, cookie-authenticated routes should independently enforce CSRF or equivalent request authorization because CORS denial alone does not prevent server-side effects.

Suggested reviewers: thebentern

Poem

A rabbit checks the origin gate
No angry error seals its fate
An empty string now marks the way
Browsers block the read today
The middleware stays calm and bright

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: returning no origin instead of throwing for disallowed CORS origins.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1 files.


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.

@jamesarich

Copy link
Copy Markdown
Contributor Author

Closing the loop on the one caveat above: CI confirmed the part I could not run locally. The build job executed prisma generate && tsc and the boot check for real (every step success, not skipped), and quality passed biome ci plus validate:maintenance-uf2. So tsc is happy with the change.

@thebentern
thebentern merged commit 52aee29 into master Sep 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants