Skip to content

🐛 Fixed CORS errors for embedded signup forms after a custom domain is added#26497

Merged
kevinansfield merged 1 commit intomainfrom
codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w
Feb 24, 2026
Merged

🐛 Fixed CORS errors for embedded signup forms after a custom domain is added#26497
kevinansfield merged 1 commit intomainfrom
codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w

Conversation

@kevinansfield
Copy link
Member

@kevinansfield kevinansfield commented Feb 18, 2026

closes https://linear.app/ghost/issue/BER-3063/

Problem

Embedded signup forms are used via copy/paste and include the currently configured URL in their data attributes. Any forms created whilst a site is on a hosted subdomain (e.g. *.ghost.io) stop working when the site switches over to custom domain due to a CORS error on the /ghost/api/members/token-integrity/ endpoint which requires a cookie to function. The request failed CORS because we returned an Access-Control-Allow-Origin: * header on the Members API for which browsers refuse to make credentialed requests cross-origin.

Summary

Replaced the blanket cors({maxAge}) middleware on the members API with an origin-aware policy. Known origins (site URL, admin URL, localhost) get the specific origin reflected back with Access-Control-Allow-Credentials: true so credentialed requests like integrity-token work correctly. All other requests (unknown, missing, or invalid origins) still get * so the public API remains as accessible as before from any site embedding Ghost widgets.

access-control-allow-origin behaviour

Request origin Before After
Site URL (config.url) * Reflected origin + credentials
Admin URL (config.admin.url) * Reflected origin + credentials
Localhost * Reflected origin + credentials
Third-party site * *
No origin header * *
Invalid origin * *

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 09b9342 and 2ff89c9.

⛔ Files ignored due to path filters (10)
  • ghost/core/test/e2e-api/admin/__snapshots__/comments.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/member-commenting.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/announcement.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/create-stripe-checkout-session.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/feedback.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/middleware.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/recommendations.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/site.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-server/__snapshots__/1-options-requests.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • ghost/core/core/server/web/members/app.js
  • ghost/core/core/server/web/members/middleware/cors.js
  • ghost/core/test/unit/server/web/members/middleware/cors.test.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/unit/server/web/members/middleware/cors.test.js
  • ghost/core/core/server/web/members/middleware/cors.js

Walkthrough

Replaces direct use of the external cors invocation in the members app with a new local CORS middleware module (ghost/core/core/server/web/members/middleware/cors.js) that determines CORS behavior (enabled, disabled, wildcard) based on configured site/admin URLs and request origin. Wires the middleware into the members app and adds unit tests validating allowed, disallowed, missing/null, and wildcard origin handling.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: fixing CORS errors for embedded signup forms when switching to a custom domain, which aligns with the primary purpose of the PR.
Description check ✅ Passed The description provides a clear explanation of the problem, the solution, and a table showing the behavior changes, all directly related to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w

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 and usage tips.

@kevinansfield kevinansfield changed the title 🐛 Fix members integrity-token CORS handling for admin.url origins 🐛 Fixed CORS errors for embedded signup forms when a custom domain is added Feb 18, 2026
@kevinansfield kevinansfield changed the title 🐛 Fixed CORS errors for embedded signup forms when a custom domain is added 🐛 Fixed CORS errors for embedded signup forms after a custom domain is added Feb 18, 2026
@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch 3 times, most recently from 4845cb4 to c9a561a Compare February 19, 2026 09:00
@kevinansfield kevinansfield marked this pull request as ready for review February 19, 2026 09:07
@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch from c9a561a to 084fe02 Compare February 19, 2026 09:13
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
ghost/core/core/server/web/members/middleware/cors.js (1)

14-15: IPv6 loopback (::1) is not covered.

The localhost allowlist covers localhost and 127.0.0.1 but misses the IPv6 loopback ::1. Local development environments using IPv6 stacks will receive wildcard CORS instead of the reflected-origin path.

♻️ Proposed fix
-if (originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1') {
+if (originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1' || originUrl.hostname === '::1') {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/web/members/middleware/cors.js` around lines 14 - 15,
The CORS allowlist only checks originUrl.hostname for 'localhost' and
'127.0.0.1' and misses the IPv6 loopback; update the condition in cors.js (the
branch that references originUrl.hostname) to also treat '::1' as allowed so
IPv6 local requests follow the reflected-origin path instead of falling back to
wildcard CORS.
ghost/core/test/unit/server/web/members/middleware/cors.test.js (3)

56-56: res.get stubs appear to be dead code.

The middleware uses req.get('origin') (confirmed by the implementation snippet) and the mock already covers res.getHeader (Line 24). The res.get stubs on the response object are never invoked and can be removed to reduce noise.

♻️ Proposed cleanup
-    res.get = sinon.stub().withArgs('origin').returns(origin);

Remove this line from all four origin-bearing tests.

Also applies to: 75-75, 94-94, 113-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js` at line 56,
The res.get stubs are dead code in the origin-bearing tests — remove the calls
that stub res.get (e.g., the line that does res.get =
sinon.stub().withArgs('origin').returns(origin)) from the four tests that
already mock req.get('origin') and res.getHeader to clean up noise; update the
tests in ghost/core/test/unit/server/web/members/middleware/cors.test.js by
deleting those res.get stubbing lines in each origin-bearing test so only
req.get and res.getHeader mocks remain.

13-20: Only OPTIONS (preflight) method is tested; actual-request CORS behavior is unverified.

req.method is hardcoded to 'OPTIONS' in beforeEach. The middleware should also set CORS headers on regular GET/POST requests (calling next() rather than res.end()). A missing test here means a regression in non-preflight handling would go undetected.

✅ Suggested additional test
+    it('should set CORS header and call next for non-OPTIONS requests with known origin', function () {
+        configUtils.set({url: 'https://my.blog'});
+        req.method = 'GET';
+
+        const origin = 'http://my.blog';
+        req.get = sinon.stub().withArgs('origin').returns(origin);
+        req.headers.origin = origin;
+
+        cors(req, res, next);
+
+        sinon.assert.calledOnce(next);
+        sinon.assert.notCalled(res.end);
+        assert.equal(res.headers['Access-Control-Allow-Origin'], origin);
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js` around lines
13 - 20, The test only covers OPTIONS (preflight) because beforeEach hardcodes
req.method = 'OPTIONS', so add a new test case for non-OPTIONS requests (e.g.,
'GET' and/or 'POST') and/or change beforeEach to default to a non-OPTIONS method
and override for the preflight test; in that new test, assert the CORS headers
are set on res and that the middleware calls next() (instead of ending the
response), referencing the test's req object and the cors middleware under test
to locate the code to update.

8-120: No test for localhost origin, which the PR explicitly documents as a known/reflected origin.

The PR description states: "Known origins (site URL config.url, admin URL config.admin.url, localhost) are reflected back." There is no test verifying that a localhost (or http://localhost:*) origin receives its own origin echoed back rather than *.

✅ Suggested additional test
+    it('should be enabled when origin is localhost', function () {
+        configUtils.set({url: 'https://my.blog'});
+
+        const origin = 'http://localhost:2368';
+        req.get = sinon.stub().withArgs('origin').returns(origin);
+        req.headers.origin = origin;
+
+        cors(req, res, next);
+
+        sinon.assert.calledOnce(res.end);
+        assert.equal(res.headers['Access-Control-Allow-Origin'], origin);
+        assert.equal(res.headers['Access-Control-Allow-Credentials'], 'true');
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js` around lines
8 - 120, Add a new unit test in the cors.test.js suite that verifies localhost
origins are reflected back: set configUtils to a normal site/admin URL, stub
req.get('origin') and res.get('origin') to return an origin like
'http://localhost:3000', set req.headers.origin accordingly, call cors(req, res,
next), assert res.end was called once and assert
res.headers['Access-Control-Allow-Origin'] === origin (not '*'), and ensure next
was not called; use the same req/res stubbing pattern as the other tests and
reference the cors middleware function and configUtils used elsewhere in the
file.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/server/web/members/middleware/cors.js`:
- Line 6: ENABLE_CORS currently lacks the credentials flag so browsers will
still block credentialed cross-origin requests; update the ENABLE_CORS object
used by the cors middleware (the const ENABLE_CORS declaration) to include
credentials: true (e.g. { origin: true, maxAge, credentials: true }) so the
server returns Access-Control-Allow-Credentials: true while still reflecting the
request origin.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js`:
- Around line 51-82: The tests for the cors middleware are missing assertions
for Access-Control-Allow-Credentials and the middleware config lacks enabling
credentials; update the cors config object (ENABLE_CORS) to include credentials:
true so the cors npm middleware will emit Access-Control-Allow-Credentials, then
update the two tests that check known origins (the tests calling cors(req, res,
next) with config.url and config.admin.url) to assert
res.headers['Access-Control-Allow-Credentials'] === 'true'; also add an
assertion in the wildcard-origin test to confirm credentials header is not set,
and consider adding a separate test case for localhost origin to cover that
branch.

---

Nitpick comments:
In `@ghost/core/core/server/web/members/middleware/cors.js`:
- Around line 14-15: The CORS allowlist only checks originUrl.hostname for
'localhost' and '127.0.0.1' and misses the IPv6 loopback; update the condition
in cors.js (the branch that references originUrl.hostname) to also treat '::1'
as allowed so IPv6 local requests follow the reflected-origin path instead of
falling back to wildcard CORS.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js`:
- Line 56: The res.get stubs are dead code in the origin-bearing tests — remove
the calls that stub res.get (e.g., the line that does res.get =
sinon.stub().withArgs('origin').returns(origin)) from the four tests that
already mock req.get('origin') and res.getHeader to clean up noise; update the
tests in ghost/core/test/unit/server/web/members/middleware/cors.test.js by
deleting those res.get stubbing lines in each origin-bearing test so only
req.get and res.getHeader mocks remain.
- Around line 13-20: The test only covers OPTIONS (preflight) because beforeEach
hardcodes req.method = 'OPTIONS', so add a new test case for non-OPTIONS
requests (e.g., 'GET' and/or 'POST') and/or change beforeEach to default to a
non-OPTIONS method and override for the preflight test; in that new test, assert
the CORS headers are set on res and that the middleware calls next() (instead of
ending the response), referencing the test's req object and the cors middleware
under test to locate the code to update.
- Around line 8-120: Add a new unit test in the cors.test.js suite that verifies
localhost origins are reflected back: set configUtils to a normal site/admin
URL, stub req.get('origin') and res.get('origin') to return an origin like
'http://localhost:3000', set req.headers.origin accordingly, call cors(req, res,
next), assert res.end was called once and assert
res.headers['Access-Control-Allow-Origin'] === origin (not '*'), and ensure next
was not called; use the same req/res stubbing pattern as the other tests and
reference the cors middleware function and configUtils used elsewhere in the
file.

@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch from 084fe02 to c229c1c Compare February 19, 2026 10:01
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
ghost/core/core/server/web/members/middleware/cors.js (1)

10-16: IPv6 loopback (::1) not covered by the localhost check.

originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1' misses ::1. Any dev tool or browser that sends Origin: http://[::1]:PORT will fall through to WILDCARD_CORS rather than ENABLE_CORS, so credentialed local requests from IPv6 stacks won't work. The fix is a one-liner:

🛠️ Proposed fix
-    if (originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1') {
+    if (originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1' || originUrl.hostname === '::1') {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/web/members/middleware/cors.js` around lines 10 - 16,
The isAllowedOrigin function currently treats localhost only as 'localhost' or
'127.0.0.1', missing IPv6 loopback; update isAllowedOrigin to also accept '::1'
by adding a check for originUrl.hostname === '::1' alongside the existing
localhost and 127.0.0.1 checks so IPv6 local origins are allowed and
credentialed local requests succeed.
ghost/core/test/unit/server/web/members/middleware/cors.test.js (1)

42-120: No test for the localhost origin path.

isAllowedOrigin in cors.js explicitly whitelists localhost and 127.0.0.1 (lines 14–16), but no test case exercises this path. Adding coverage ensures regressions are caught if the localhost logic changes.

🧪 Suggested localhost test
+    it('should be enabled when origin is localhost', function () {
+        configUtils.set({url: 'https://my.blog'});
+
+        const origin = 'http://localhost:2368';
+        req.get = sinon.stub().withArgs('origin').returns(origin);
+        req.headers.origin = origin;
+
+        cors(req, res, next);
+
+        sinon.assert.calledOnce(res.end);
+        assert.equal(res.headers['Access-Control-Allow-Origin'], origin);
+        assert.equal(res.headers['Access-Control-Allow-Credentials'], 'true');
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js` around lines
42 - 120, Add a unit test that exercises the localhost whitelist in
isAllowedOrigin by stubbing req.get/res.get and setting req.headers.origin to a
localhost origin (e.g. 'http://localhost:3000' or 'http://127.0.0.1:3000'), then
call cors(req, res, next) and assert that res.end was called and
res.headers['Access-Control-Allow-Origin'] equals the provided origin; place
this alongside the existing tests (referencing the cors function and
isAllowedOrigin behavior) to ensure the localhost path is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js`:
- Around line 51-82: The two known-origin tests in cors.test.js that call
cors(req, res, next) (the "config.url" and "config.admin.url" it blocks)
currently assert Access-Control-Allow-Origin but do not assert the credentials
header; add assertions after the existing origin checks to verify that the
response includes the Access-Control-Allow-Credentials header and that its value
is true; additionally, update the wildcard-path test to assert that
Access-Control-Allow-Credentials is not set (undefined) for the wildcard case so
the negative case is covered.

---

Nitpick comments:
In `@ghost/core/core/server/web/members/middleware/cors.js`:
- Around line 10-16: The isAllowedOrigin function currently treats localhost
only as 'localhost' or '127.0.0.1', missing IPv6 loopback; update
isAllowedOrigin to also accept '::1' by adding a check for originUrl.hostname
=== '::1' alongside the existing localhost and 127.0.0.1 checks so IPv6 local
origins are allowed and credentialed local requests succeed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js`:
- Around line 42-120: Add a unit test that exercises the localhost whitelist in
isAllowedOrigin by stubbing req.get/res.get and setting req.headers.origin to a
localhost origin (e.g. 'http://localhost:3000' or 'http://127.0.0.1:3000'), then
call cors(req, res, next) and assert that res.end was called and
res.headers['Access-Control-Allow-Origin'] equals the provided origin; place
this alongside the existing tests (referencing the cors function and
isAllowedOrigin behavior) to ensure the localhost path is covered.

@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch 2 times, most recently from e78bcb4 to 09b9342 Compare February 19, 2026 10:27
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
ghost/core/test/unit/server/web/members/middleware/cors.test.js (2)

13-20: All tests exercise only preflight (OPTIONS); missing coverage for actual requests

Every test uses req.method: 'OPTIONS', which causes the cors middleware to call res.end(). The actual request (e.g. GET, POST) response must also include Access-Control-Allow-Credentials: true. Consider adding at least one test with method: 'POST' for a known origin, asserting the CORS headers are set and next is called rather than res.end.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js` around lines
13 - 20, Add a test in cors.test.js that uses a non-OPTIONS method (e.g. set
req.method = 'POST' in a new it block) so the cors middleware path for actual
requests runs; for that test provide a known origin in req.headers.origin, stub
res.setHeader and res.end, and assert that res.setHeader was called with
"Access-Control-Allow-Credentials" set to "true" (and other CORS headers) and
that next() was invoked while res.end was NOT called; update any existing
beforeEach or add a new test-specific req override so other tests still exercise
preflight behavior.

56-56: res.get stub is dead code

res.get is stubbed at lines 56 and 75, but neither the custom middleware (req.get('origin') at line 38 of cors.js) nor the cors npm package (which reads req.headers.origin directly) ever calls res.get(). These stubs can be removed.

🧹 Proposed cleanup
-        res.get = sinon.stub().withArgs('origin').returns(origin);
         req.headers.origin = origin;

Apply identically at line 75.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js` at line 56,
Remove the dead res.get stubs in the unit test: delete the sinon.stub() calls
that set res.get to return origin in cors.test.js (both occurrences where
res.get is stubbed), because neither the middleware (cors.js uses
req.get('origin')) nor the cors package (reads req.headers.origin) call res.get;
simply remove those two res.get stubbing lines and run tests to confirm no other
references rely on res.get.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js`:
- Around line 51-82: The tests for known-origin paths (the two it blocks that
call cors(req, res, next) with origins matching config.url and config.admin.url)
are missing assertions that Access-Control-Allow-Credentials is set to "true"
and the wildcard-origin tests are missing assertions that this header is not
present; update those tests to assert
res.headers['Access-Control-Allow-Credentials'] === 'true' for the
reflected-origin cases and assert
res.headers['Access-Control-Allow-Credentials'] is undefined (or not present)
for the wildcard-origin cases, and before relying on those assertions confirm
that the middleware's ENABLE_CORS configuration (in cors.js) includes
credentials: true or otherwise ensures credentials are enabled at request-time.
- Around line 8-121: Add a new unit test in the members cors middleware suite
that asserts localhost origins are reflected and credentials are enabled: in
ghost/core/test/unit/server/web/members/middleware/cors.test.js add a test
similar to the "should be enabled when origin matches config.url host" case that
sets req.get/res.get to return 'http://localhost:2368' (and req.headers.origin
accordingly), calls cors(req, res, next) and then asserts
res.headers['Access-Control-Allow-Origin'] equals that origin and
res.headers['Access-Control-Allow-Credentials'] === 'true', and still verifies
res.end was called; locate the test near the other origin-matching tests
referencing the cors function and existing req/res setup.

---

Nitpick comments:
In `@ghost/core/test/unit/server/web/members/middleware/cors.test.js`:
- Around line 13-20: Add a test in cors.test.js that uses a non-OPTIONS method
(e.g. set req.method = 'POST' in a new it block) so the cors middleware path for
actual requests runs; for that test provide a known origin in
req.headers.origin, stub res.setHeader and res.end, and assert that
res.setHeader was called with "Access-Control-Allow-Credentials" set to "true"
(and other CORS headers) and that next() was invoked while res.end was NOT
called; update any existing beforeEach or add a new test-specific req override
so other tests still exercise preflight behavior.
- Line 56: Remove the dead res.get stubs in the unit test: delete the
sinon.stub() calls that set res.get to return origin in cors.test.js (both
occurrences where res.get is stubbed), because neither the middleware (cors.js
uses req.get('origin')) nor the cors package (reads req.headers.origin) call
res.get; simply remove those two res.get stubbing lines and run tests to confirm
no other references rely on res.get.

@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch from 09b9342 to f121600 Compare February 19, 2026 11:23
@kevinansfield
Copy link
Member Author

kevinansfield commented Feb 19, 2026

Context: CORS middleware comparison

We now have three cors.js files handling different parts of the API. Here's how they relate:

Commonalities

All three files:

  • Use the cors npm package with a corsOptionsDelegate pattern
  • Check the Origin header and short-circuit when missing/"null"
  • Allow localhost/127.0.0.1 origins
  • Allow the configured site URL host
  • Allow the configured admin:url host (if set)
  • Read maxAge from config.get('caching:cors:maxAge')
  • All get Vary: Origin added by the cors library when the origin is dynamically determined

Differences

Aspect Frontend (frontend/web) Admin API (server/web/api) Members API (server/web/members)
Default (no origin) origin: false (deny) origin: false (deny) origin: '*' (wildcard)
Unrecognized origin origin: false (deny) origin: false (deny) origin: '*' (wildcard)
Credentials credentials: true Not set credentials: true (for known origins)
Localhost check hostname match via new URL() hostname match via os.networkInterfaces() + allowlist hostname match via new URL()
URL parsing new URL() (WHATWG) url.parse() (legacy Node) new URL() (WHATWG)
Error handling Throws BadRequestError on invalid origin No try/catch (would crash on bad URL) Catches errors, falls back to wildcard
Extra Vary handling Adds explicit Vary: Origin on OPTIONS via handleCaching Adds explicit Vary: Origin on OPTIONS via handleCaching Relies solely on cors library's automatic Vary
Local IP detection Only localhost + 127.0.0.1 Enumerates all network interfaces (os.networkInterfaces()) Only localhost + 127.0.0.1
Export shape [handleCaching, cors()] (array) [handleCaching, cors()] (array) cors() (single middleware)

Key observations

  1. The Admin API file is the oldest-style — it uses the deprecated url.parse(), enumerates all local IPs via os.networkInterfaces(), and uses a cached allowlist. The other two use the modern WHATWG URL constructor.

  2. The Members API is the most permissive — unrecognized or missing origins get origin: '*' (wildcard CORS), meaning any website can make requests to the Members API. This makes sense because the Members API is called from arbitrary customer sites embedding signup forms, portal, comments, etc. However, credentials: true is only sent for known origins (site/admin/localhost).

  3. The Frontend file is the only one that returns an error on a malformed origin (BadRequestError). The Members file silently falls back to wildcard, and the Admin API file has no protection against malformed URLs.

  4. There's meaningful duplication — the origin-checking logic (localhost, siteUrl, adminUrl) is essentially the same in the Frontend and Members files, while the Admin API does the same thing via a different mechanism (allowlist array with indexOf). This could potentially be extracted into a shared helper.

@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch 2 times, most recently from 35ed990 to 38db64b Compare February 19, 2026 12:42
const ENABLE_CORS = {origin: true, maxAge, credentials: true};
const WILDCARD_CORS = {origin: '*', maxAge};

function isAllowedOrigin(origin) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

If isAllowedOrigin handled the try/catch for URL construction internally and also handled null/missing origins (returning false), then the corsOptionsDelegate below could collapse into a single if statement, something like:

function corsOptionsDelegate(req, callback) {
    const origin = req.get('origin');
    if (isAllowedOrigin(origin)) {
        return callback(null, ENABLE_CORS);
    }
    return callback(null, WILDCARD_CORS);
}

Not a big deal tho!

closes https://linear.app/ghost/issue/BER-3063/

Replaced blanket wildcard CORS on the members API with an origin-aware
policy so credentialed requests (integrity-token) work when making
requests from `{url}` to `{admin.url}` as is the case for embedded
signup forms after a hosted site adds a custom domain.
@kevinansfield kevinansfield force-pushed the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch from 38db64b to 2ff89c9 Compare February 24, 2026 09:23
@kevinansfield kevinansfield merged commit 607308d into main Feb 24, 2026
63 of 66 checks passed
@kevinansfield kevinansfield deleted the codex/linear-mention-ber-3063-embeddable-sign-up-forms-break-w branch February 24, 2026 12:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants