Skip to content

Fixed member session cookies not scoped to subdirectory path#27554

Merged
9larsons merged 5 commits into
TryGhost:mainfrom
muratcorlu:fix/member-cookie-path-subdirectory
Apr 29, 2026
Merged

Fixed member session cookies not scoped to subdirectory path#27554
9larsons merged 5 commits into
TryGhost:mainfrom
muratcorlu:fix/member-cookie-path-subdirectory

Conversation

@muratcorlu
Copy link
Copy Markdown
Contributor

@muratcorlu muratcorlu commented Apr 25, 2026

Ghost supports subdirectory installs via the url config (e.g. https://example.com/tr/), and some hosting setups run two Ghost sites on the same domain — one at the root path and one in a subdirectory. In this scenario, member session cookies from the subdirectory site are sent to the root site on every request (because the cookie Path defaults to /). The root site cannot validate the foreign cookie and immediately expires it with a Set-Cookie: ghost-members-ssr=; Max-Age=0; Path=/, which deletes the member's session on the subdirectory site.

The admin session cookie (ghost-admin-api-session) already handles this correctly by setting path: urlUtils.getSubdir() + '/ghost'. This commit applies the same treatment to the members-side cookies:

  • ghost-members-ssr (primary member session): pass cookiePath derived from urlUtils.getSubdir() when constructing MembersSSR in service.js. The option already exists in the @tryghost/members-ssr package but was never wired up.
  • ghost-access / ghost-access-hmac (cache tier cookies in middleware.js): replace hardcoded Path=/ string literals with a value derived from urlUtils.getSubdir().

For root installs getSubdir() returns '', so the path falls back to '/' — no behaviour change for the common case.

Got some code for us? Awesome 🎊!

Please take a minute to explain the change you're making:

  • Why are you making it?
  • What does it do?
  • Why is this something Ghost users or developers need?

Please check your PR against these items:

  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

We appreciate your contribution! 🙏

Ghost supports subdirectory installs via the `url` config (e.g.
`https://example.com/tr/`), and some hosting setups run two Ghost
sites on the same domain — one at the root path and one in a
subdirectory. In this scenario, member session cookies from the
subdirectory site are sent to the root site on every request
(because the cookie Path defaults to `/`). The root site cannot
validate the foreign cookie and immediately expires it with a
`Set-Cookie: ghost-members-ssr=; Max-Age=0; Path=/`, which deletes
the member's session on the subdirectory site.

The admin session cookie (`ghost-admin-api-session`) already handles
this correctly by setting `path: urlUtils.getSubdir() + '/ghost'`.
This commit applies the same treatment to the members-side cookies:

- `ghost-members-ssr` (primary member session): pass `cookiePath`
  derived from `urlUtils.getSubdir()` when constructing `MembersSSR`
  in `service.js`. The option already exists in the `@tryghost/members-ssr`
  package but was never wired up.
- `ghost-access` / `ghost-access-hmac` (cache tier cookies in
  `middleware.js`): replace hardcoded `Path=/` string literals with
  a value derived from `urlUtils.getSubdir()`.

For root installs `getSubdir()` returns `''`, so the path falls back
to `'/'` — no behaviour change for the common case.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 680c62fe-1271-4958-969f-54776ef9289d

📥 Commits

Reviewing files that changed from the base of the PR and between a073bb7 and 7461b54.

📒 Files selected for processing (1)
  • ghost/core/core/server/services/members/service.js

Walkthrough

Cookie Path handling for members cookies now uses urlUtils.getSubdir() with a fallback to / instead of always using /. The derived path is applied when expiring ghost-access and ghost-access-hmac cookies and when setting new signed cookies. MembersSSR configuration gains a cookiePath set the same way. Unit tests were added to assert Set-Cookie headers for root (Path=/) and subdirectory (Path=/subdir) installs, including clearing existing cookies.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: member session cookies now being properly scoped to subdirectory paths, which is the core objective of the changeset.
Description check ✅ Passed The description is thorough and directly related to the changeset, explaining the problem, solution, affected cookies, and why the fix matters for subdirectory deployments.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

Copy link
Copy Markdown
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/services/members/middleware.test.js (1)

324-423: LGTM — covers both the set and clear branches across root vs subdir.

Tests directly assert the regression is fixed for: (1) active free-tier subscription (cookie set with Path=/ vs Path=/subdir), and (2) member === null clearing path (ghost-access=null with the corresponding Path). Triggering the on-headers callback via res.writeHead(200) is a clean way to exercise setAccessCookies without exporting it.

A couple of minor cleanups you can take or leave:

  • Line 346: onHeaders(res, function () {}); is redundant — accessInfoSession itself registers an onHeaders listener, which already patches res.writeHead.
  • Lines 401 and 414: res.getHeader.returns([]) re-stubs the same default already set in beforeEach (line 339); these lines can be removed.
♻️ Optional cleanup
-            // on-headers patches writeHead so registered callbacks fire before it
-            onHeaders(res, function () {});
             next = sinon.stub();
         it('clears cookies with Path=/ for root site installs', async function () {
             sinon.stub(urlUtils, 'getSubdir').returns('');
             req.headers.cookie = 'ghost-access=stale';
-            res.getHeader.returns([]);

             await runAndFlushHeaders(null);
         it('clears cookies with Path=/subdir for subdirectory site installs', async function () {
             sinon.stub(urlUtils, 'getSubdir').returns('/subdir');
             req.headers.cookie = 'ghost-access=stale';
-            res.getHeader.returns([]);

             await runAndFlushHeaders(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/members/middleware.test.js` around lines
324 - 423, Remove the redundant on-headers patch and duplicate stubs: delete the
explicit onHeaders(res, function () {}) call (it's unnecessary because
membersMiddleware.accessInfoSession already registers an onHeaders listener) and
remove the duplicated res.getHeader.returns([]) calls inside the two "clears
cookies" tests (they repeat the beforeEach default). Keep the rest of the tests
and the runAndFlushHeaders helper unchanged.
ghost/core/core/server/services/members/middleware.js (1)

47-49: LGTM — Path correctly derived from subdir on both clear and set paths.

Both branches now scope ghost-access / ghost-access-hmac to the configured subdirectory, matching the new cookiePath passed to MembersSSR in service.js. RFC 6265 path matching makes a value like Path=/tr (no trailing slash) correctly match /tr/... without leaking into siblings such as /track, so the format is safe.

Optional: the expression urlUtils.getSubdir() || '/' is now repeated three times across service.js:137, middleware.js:47, and middleware.js:72. If you'd like, factor it into a small helper (e.g. urlUtils.getCookiePath() or a local getCookiePath()) so the fallback logic only lives in one place.

Also applies to: 72-74

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

In `@ghost/core/core/server/services/members/middleware.js` around lines 47 - 49,
The repeated expression urlUtils.getSubdir() || '/' is duplicated across
MembersSSR in service.js and the cookie-setting/clearing code in middleware.js
(where ghost-access and ghost-access-hmac Path is computed); create a single
helper (either add urlUtils.getCookiePath() or a local getCookiePath()) that
returns urlUtils.getSubdir() || '/' and replace the three occurrences (the call
in MembersSSR in service.js and the two cookie Path computations in
middleware.js) to use that helper so the fallback logic is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/members/middleware.js`:
- Around line 47-49: The repeated expression urlUtils.getSubdir() || '/' is
duplicated across MembersSSR in service.js and the cookie-setting/clearing code
in middleware.js (where ghost-access and ghost-access-hmac Path is computed);
create a single helper (either add urlUtils.getCookiePath() or a local
getCookiePath()) that returns urlUtils.getSubdir() || '/' and replace the three
occurrences (the call in MembersSSR in service.js and the two cookie Path
computations in middleware.js) to use that helper so the fallback logic is
centralized.

In `@ghost/core/test/unit/server/services/members/middleware.test.js`:
- Around line 324-423: Remove the redundant on-headers patch and duplicate
stubs: delete the explicit onHeaders(res, function () {}) call (it's unnecessary
because membersMiddleware.accessInfoSession already registers an onHeaders
listener) and remove the duplicated res.getHeader.returns([]) calls inside the
two "clears cookies" tests (they repeat the beforeEach default). Keep the rest
of the tests and the runAndFlushHeaders helper unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0a2306dc-2bb6-4f43-bc3b-f492cbb707f5

📥 Commits

Reviewing files that changed from the base of the PR and between 405ca7c and 099816f.

📒 Files selected for processing (3)
  • ghost/core/core/server/services/members/middleware.js
  • ghost/core/core/server/services/members/service.js
  • ghost/core/test/unit/server/services/members/middleware.test.js

tiersService.api is null until init() runs, so sinon.stub(tiersService.api, 'browse')
throws "Trying to stub property of null". Replace with a direct assignment of a mock
object, restored in afterEach.
Copy link
Copy Markdown
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: 1

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

385-385: Brittle Path=/; match relies on Path not being the last cookie attribute.

The assertions require a trailing ; after the path (Path=/;, Path=/subdir;, and the regexes Path=\/; / Path=\/subdir;). If the underlying cookie serializer ever emits Path as the last attribute (e.g. ... HttpOnly; Path=/), every assertion in this block silently fails even though the production behaviour is correct. Anchoring on (;|$) (or matching ; Path=/<value> and accepting end-of-string) would make the tests resilient to attribute-order changes without weakening them.

Also applies to: 400-400, 412-412, 424-424

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

In `@ghost/core/test/unit/server/services/members/middleware.test.js` at line 385,
The test assertions that check cookie strings (e.g., the
assert.ok(cookies.some(c => c.includes('Path=/;'))) predicate) are brittle
because they require a trailing semicolon; change these checks to accept either
a trailing semicolon or end-of-string by using a regex or adjusted includes
check (for example test for /Path=\/(;|$)/ or /Path=\/subdir(;|$)/) wherever the
current assertions use 'Path=/;' or 'Path=/subdir;'. Update the lambda
predicates (cookies.some(c => ...)) in the test cases to use the new regex match
so attribute ordering (Path last or not) won’t break the tests.

349-354: Prefer sinon.stub(tiersService, 'api').get(...) over direct property mutation.

Reassigning tiersService.api and manually restoring it in afterEach works, but the rest of this file uses sinon.stub(membersService, 'api').get(() => …) (e.g. line 251) for the same pattern. Switching to that style means you don't need originalTiersApi, the afterEach ordering becomes irrelevant, and sinon.restore() handles teardown uniformly. Purely a consistency nit — feel free to ignore.

Also applies to: 362-362

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

In `@ghost/core/test/unit/server/services/members/middleware.test.js` around lines
349 - 354, The test mutates tiersService.api directly and saves
originalTiersApi; instead use sinon.stub(tiersService, 'api').get(() => ({
browse: sinon.stub().resolves({data: [{id: freeTierId, type: 'free'}]}) })) so
teardown is handled by sinon.restore() and you can remove originalTiersApi and
any manual afterEach restore; apply the same change to the other occurrence at
line 362 and ensure any references to tiersService.api in the test read from the
stubbed getter.
🤖 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/test/unit/server/services/members/middleware.test.js`:
- Around line 381-400: Update the tests to assert the path (and null-clearing)
for each cookie individually instead of using .some(...); locate the assertions
after runAndFlushHeaders in middleware.test.js and replace the loose check that
inspects the Set-Cookie array with explicit checks that the Set-Cookie entries
for 'ghost-access' and 'ghost-access-hmac' each contain the expected Path (e.g.,
'Path=/' or 'Path=/subdir') and that clearing behavior asserts null for both
cookies; reference the test helper runAndFlushHeaders and the cookie names
'ghost-access' and 'ghost-access-hmac' when making the targeted assertions so
both cookie code paths in middleware.js are verified.

---

Nitpick comments:
In `@ghost/core/test/unit/server/services/members/middleware.test.js`:
- Line 385: The test assertions that check cookie strings (e.g., the
assert.ok(cookies.some(c => c.includes('Path=/;'))) predicate) are brittle
because they require a trailing semicolon; change these checks to accept either
a trailing semicolon or end-of-string by using a regex or adjusted includes
check (for example test for /Path=\/(;|$)/ or /Path=\/subdir(;|$)/) wherever the
current assertions use 'Path=/;' or 'Path=/subdir;'. Update the lambda
predicates (cookies.some(c => ...)) in the test cases to use the new regex match
so attribute ordering (Path last or not) won’t break the tests.
- Around line 349-354: The test mutates tiersService.api directly and saves
originalTiersApi; instead use sinon.stub(tiersService, 'api').get(() => ({
browse: sinon.stub().resolves({data: [{id: freeTierId, type: 'free'}]}) })) so
teardown is handled by sinon.restore() and you can remove originalTiersApi and
any manual afterEach restore; apply the same change to the other occurrence at
line 362 and ensure any references to tiersService.api in the test read from the
stubbed getter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d587b79f-04ef-4ce0-898c-83bf0ffaf27d

📥 Commits

Reviewing files that changed from the base of the PR and between 1cc3917 and cd8031e.

📒 Files selected for processing (1)
  • ghost/core/test/unit/server/services/members/middleware.test.js

Comment thread ghost/core/test/unit/server/services/members/middleware.test.js Outdated
Copy link
Copy Markdown
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/services/members/middleware.test.js (2)

339-339: Nit: _headers: {} is unused.

The _headers field on the mock res is never read by the tests or by setAccessCookies (which uses res.getHeader/res.setHeader). Safe to drop.

🧹 Cleanup
             res = {
-                _headers: {},
                 getHeader: sinon.stub().returns([]),
                 setHeader: sinon.stub(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/members/middleware.test.js` at line 339,
The test's mock response object includes an unused property `_headers` which is
never read by the tests or by setAccessCookies (which interacts via
res.getHeader/res.setHeader); remove the `_headers` field from the mock `res`
object in the unit tests (references: the mock `res` definition in
middleware.test.js and the setAccessCookies codepath) so the mock only exposes
the headers via getHeader/setHeader and does not include `_headers`.

373-443: Optional: extract a small helper to deduplicate the 4 tests.

Each test repeats the same extraction-and-assertion block (find Set-Cookie, locate both cookies, assert their presence, then check Path=...). A tiny helper would make the path/clearing matrix easier to read and extend (e.g., for an HMAC-disabled case in the future).

♻️ Example refactor
+        function getAccessCookies() {
+            const setCookieArgs = res.setHeader.args.find(args => args[0] === 'Set-Cookie');
+            assert.ok(setCookieArgs, 'Set-Cookie header should be set');
+            const cookies = setCookieArgs[1];
+            const accessCookie = cookies.find(c => c.startsWith('ghost-access='));
+            const hmacCookie = cookies.find(c => c.startsWith('ghost-access-hmac='));
+            assert.ok(accessCookie, 'ghost-access cookie should be set');
+            assert.ok(hmacCookie, 'ghost-access-hmac cookie should be set');
+            return {accessCookie, hmacCookie};
+        }
+
         it('uses Path=/ for root site installs', async function () {
             sinon.stub(urlUtils, 'getSubdir').returns('');
-
-            const member = {
-                subscriptions: [{status: 'active', tier: {id: freeTierId}}]
-            };
-            await runAndFlushHeaders(member);
-
-            const setCookieArgs = res.setHeader.args.find(args => args[0] === 'Set-Cookie');
-            assert.ok(setCookieArgs, 'Set-Cookie header should be set');
-            const cookies = setCookieArgs[1];
-            const accessCookie = cookies.find(c => c.startsWith('ghost-access='));
-            const hmacCookie = cookies.find(c => c.startsWith('ghost-access-hmac='));
-            assert.ok(accessCookie, 'ghost-access cookie should be set');
-            assert.ok(hmacCookie, 'ghost-access-hmac cookie should be set');
-            assert.ok(accessCookie.includes('Path=/;'), `Expected Path=/ in ghost-access: ${accessCookie}`);
-            assert.ok(hmacCookie.includes('Path=/;'), `Expected Path=/ in ghost-access-hmac: ${hmacCookie}`);
+            await runAndFlushHeaders({subscriptions: [{status: 'active', tier: {id: freeTierId}}]});
+            const {accessCookie, hmacCookie} = getAccessCookies();
+            assert.ok(accessCookie.includes('Path=/;'), `Expected Path=/ in ghost-access: ${accessCookie}`);
+            assert.ok(hmacCookie.includes('Path=/;'), `Expected Path=/ in ghost-access-hmac: ${hmacCookie}`);
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/members/middleware.test.js` around lines
373 - 443, Extract a small test helper (e.g., checkCookiesPath(expectedPath,
isClearing=false)) to replace the duplicated extraction-and-assertion logic used
in the four tests that call runAndFlushHeaders; the helper should locate the
Set-Cookie header via res.setHeader.args.find, pull the cookies array, find the
strings starting with 'ghost-access=' and 'ghost-access-hmac=', assert both
exist, and then assert the correct Path value (and for clearing tests assert
value null via a regex or startsWith check). Replace repeated blocks in the
tests titled "uses Path=..." and "clears cookies..." to call this helper with
expectedPath '' mapped to '/' or '/subdir' and isClearing true for the clearing
variants; keep existing stubbing of urlUtils.getSubdir and the
runAndFlushHeaders calls as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/test/unit/server/services/members/middleware.test.js`:
- Line 339: The test's mock response object includes an unused property
`_headers` which is never read by the tests or by setAccessCookies (which
interacts via res.getHeader/res.setHeader); remove the `_headers` field from the
mock `res` object in the unit tests (references: the mock `res` definition in
middleware.test.js and the setAccessCookies codepath) so the mock only exposes
the headers via getHeader/setHeader and does not include `_headers`.
- Around line 373-443: Extract a small test helper (e.g.,
checkCookiesPath(expectedPath, isClearing=false)) to replace the duplicated
extraction-and-assertion logic used in the four tests that call
runAndFlushHeaders; the helper should locate the Set-Cookie header via
res.setHeader.args.find, pull the cookies array, find the strings starting with
'ghost-access=' and 'ghost-access-hmac=', assert both exist, and then assert the
correct Path value (and for clearing tests assert value null via a regex or
startsWith check). Replace repeated blocks in the tests titled "uses Path=..."
and "clears cookies..." to call this helper with expectedPath '' mapped to '/'
or '/subdir' and isClearing true for the clearing variants; keep existing
stubbing of urlUtils.getSubdir and the runAndFlushHeaders calls as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cfa1363c-2b1e-4dd3-bccf-069de69f31f6

📥 Commits

Reviewing files that changed from the base of the PR and between cd8031e and a073bb7.

📒 Files selected for processing (1)
  • ghost/core/test/unit/server/services/members/middleware.test.js

@muratcorlu
Copy link
Copy Markdown
Contributor Author

Hey @JohnONolan, can someone have a look at this PR? It's kind of a blocker to run multiple Ghost sites under the same domain. Thank you!

@JohnONolan
Copy link
Copy Markdown
Member

On it

Copy link
Copy Markdown
Contributor

@9larsons 9larsons left a comment

Choose a reason for hiding this comment

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

Looks sound, sorry for the delay.

I have some nits on how this is all structured (e.g. why are we setting the path in so many places) but that's well out of scope here!

@9larsons 9larsons merged commit a166ec6 into TryGhost:main Apr 29, 2026
43 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.

3 participants