From 51b6c97ecd0e4cbf1b790f30bc4aaeaae2e2d38d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:56:32 -0400 Subject: [PATCH 1/4] fix(core): keep the login cookie in Safari on http://localhost --- .../faustwp-core/src/server/auth/cookie.ts | 38 ++++++++- .../faustwp-core/src/server/auth/token.ts | 2 +- .../tests/server/auth/cookie.test.ts | 81 ++++++++++++++++++- .../tests/server/auth/token.test.ts | 21 +++++ 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/packages/faustwp-core/src/server/auth/cookie.ts b/packages/faustwp-core/src/server/auth/cookie.ts index bc130766b..3b12f2227 100644 --- a/packages/faustwp-core/src/server/auth/cookie.ts +++ b/packages/faustwp-core/src/server/auth/cookie.ts @@ -9,6 +9,35 @@ export interface CookieOptions { isJson?: boolean; } +const LOOPBACK_HOST = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/i; + +function firstHeaderValue(value?: string | string[]): string { + const header = Array.isArray(value) ? value[0] : value; + + return (header ?? '').split(',')[0].trim().toLowerCase(); +} + +/** + * Whether cookies set in response to this request should use the Secure + * attribute. + * + * Secure is only left off for plain HTTP requests to a loopback host, such as + * http://localhost during development. Safari discards Secure cookies there, + * unlike other browsers, which loses the login session right after logging in. + */ +export function isSecureCookieRequest(req: IncomingMessage): boolean { + const forwardedProto = firstHeaderValue(req.headers?.['x-forwarded-proto']); + const encrypted = (req.socket as { encrypted?: boolean } | undefined) + ?.encrypted; + const protocol = forwardedProto || (encrypted ? 'https' : 'http'); + const host = ( + firstHeaderValue(req.headers?.['x-forwarded-host']) || + firstHeaderValue(req.headers?.host) + ).replace(/:\d+$/, ''); + + return !(protocol === 'http' && LOOPBACK_HOST.test(host)); +} + export class Cookies { private request: IncomingMessage; @@ -16,9 +45,16 @@ export class Cookies { private cookies: Record = {}; + /** + * Whether cookies for this request should use the Secure attribute. See + * `isSecureCookieRequest`. + */ + public readonly secure: boolean; + constructor(req: IncomingMessage, res?: ServerResponse) { this.request = req; this.response = res; + this.secure = isSecureCookieRequest(req); this.cookies = parseCookie(this.request.headers.cookie || ''); } @@ -72,7 +108,7 @@ export class Cookies { stringifySetCookie(key, '', { path: '/', sameSite: 'strict', - secure: true, + secure: this.secure, httpOnly: true, expires: new Date(0), }), diff --git a/packages/faustwp-core/src/server/auth/token.ts b/packages/faustwp-core/src/server/auth/token.ts index fdbc29ee0..1296cdeec 100644 --- a/packages/faustwp-core/src/server/auth/token.ts +++ b/packages/faustwp-core/src/server/auth/token.ts @@ -55,7 +55,7 @@ export class OAuth { maxAge, path: '/', sameSite: 'strict', - secure: true, + secure: this.cookies.secure, httpOnly: true, }); } diff --git a/packages/faustwp-core/tests/server/auth/cookie.test.ts b/packages/faustwp-core/tests/server/auth/cookie.test.ts index 07e3ec2b1..45922ab82 100644 --- a/packages/faustwp-core/tests/server/auth/cookie.test.ts +++ b/packages/faustwp-core/tests/server/auth/cookie.test.ts @@ -1,6 +1,9 @@ import { IncomingMessage, ServerResponse } from 'http'; import { stringifySetCookie } from 'cookie'; -import { Cookies } from '../../../src/server/auth/cookie'; +import { + Cookies, + isSecureCookieRequest, +} from '../../../src/server/auth/cookie'; import { base64Encode } from '../../../src/utils'; describe('server/auth/cookie', () => { @@ -275,4 +278,80 @@ describe('server/auth/cookie', () => { expect(cookies.getCookie('no_res_key')).toBeUndefined(); }); }); + + describe('isSecureCookieRequest', () => { + const request = ( + headers: Record, + encrypted?: boolean, + ) => ({ headers, socket: { encrypted } } as any as IncomingMessage); + + test.each([ + ['localhost:3000'], + ['localhost'], + ['127.0.0.1:3000'], + ['[::1]:3000'], + ['app.localhost:3000'], + ])('is false for plain HTTP to loopback host %s', (host) => { + expect(isSecureCookieRequest(request({ host }))).toBe(false); + }); + + test('is true for plain HTTP to a non-loopback host', () => { + expect(isSecureCookieRequest(request({ host: 'example.com' }))).toBe( + true, + ); + expect(isSecureCookieRequest(request({ host: 'mylocalhost.com' }))).toBe( + true, + ); + }); + + test('is true for HTTPS to localhost', () => { + expect( + isSecureCookieRequest(request({ host: 'localhost:3000' }, true)), + ).toBe(true); + expect( + isSecureCookieRequest( + request({ host: 'localhost:3000', 'x-forwarded-proto': 'https' }), + ), + ).toBe(true); + }); + + test('uses the forwarded host behind a proxy', () => { + expect( + isSecureCookieRequest( + request({ + host: 'localhost:3000', + 'x-forwarded-host': 'example.com', + 'x-forwarded-proto': 'http', + }), + ), + ).toBe(true); + }); + + test('is true when the host is unknown', () => { + expect(isSecureCookieRequest({ headers: {} } as IncomingMessage)).toBe( + true, + ); + }); + + test('removeCookie leaves Secure off on plain HTTP localhost', () => { + const setHeaderSpy = jest.fn(); + const cookies = new Cookies(request({ host: 'localhost:3000' }), { + setHeader: setHeaderSpy, + } as any); + + expect(cookies.secure).toBe(false); + cookies.removeCookie('rt'); + + expect(setHeaderSpy).toHaveBeenCalledWith( + 'Set-Cookie', + stringifySetCookie('rt', '', { + path: '/', + sameSite: 'strict', + secure: false, + httpOnly: true, + expires: new Date(0), + }), + ); + }); + }); }); diff --git a/packages/faustwp-core/tests/server/auth/token.test.ts b/packages/faustwp-core/tests/server/auth/token.test.ts index 092780650..56908ccd0 100644 --- a/packages/faustwp-core/tests/server/auth/token.test.ts +++ b/packages/faustwp-core/tests/server/auth/token.test.ts @@ -101,6 +101,7 @@ describe('server/auth/token (OAuth)', () => { const mockCookies = { setCookie: jest.fn(), removeCookie: jest.fn(), + secure: true, } as unknown as Cookies; const oauth = new OAuth(mockCookies); @@ -124,6 +125,7 @@ describe('server/auth/token (OAuth)', () => { const mockCookies = { setCookie: jest.fn(), removeCookie: jest.fn(), + secure: true, } as unknown as Cookies; const oauth = new OAuth(mockCookies); @@ -145,6 +147,25 @@ describe('server/auth/token (OAuth)', () => { }); }); + describe('setRefreshToken on plain HTTP localhost', () => { + test('sets the cookie without Secure when Cookies says the request is not secure', () => { + const mockCookies = { + setCookie: jest.fn(), + removeCookie: jest.fn(), + secure: false, + } as unknown as Cookies; + + const oauth = new OAuth(mockCookies); + oauth.setRefreshToken('valid-rt-token'); + + expect(mockCookies.setCookie).toHaveBeenCalledWith( + 'httpsmy-wp-site.com-rt', + 'valid-rt-token', + expect.objectContaining({ secure: false, httpOnly: true }), + ); + }); + }); + describe('isOAuthTokens', () => { const mockCookies = {} as unknown as Cookies; const oauth = new OAuth(mockCookies); From 4ebad92059cc7d2f43f3c1efd220e058fdc4ee70 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:57:05 -0400 Subject: [PATCH 2/4] docs(core): link the WebKit bug behind the localhost cookie exception --- packages/faustwp-core/src/server/auth/cookie.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/faustwp-core/src/server/auth/cookie.ts b/packages/faustwp-core/src/server/auth/cookie.ts index 3b12f2227..327401d92 100644 --- a/packages/faustwp-core/src/server/auth/cookie.ts +++ b/packages/faustwp-core/src/server/auth/cookie.ts @@ -24,6 +24,8 @@ function firstHeaderValue(value?: string | string[]): string { * Secure is only left off for plain HTTP requests to a loopback host, such as * http://localhost during development. Safari discards Secure cookies there, * unlike other browsers, which loses the login session right after logging in. + * See https://bugs.webkit.org/show_bug.cgi?id=218980. Remove this exception + * once supported versions of Safari treat loopback hosts as secure. */ export function isSecureCookieRequest(req: IncomingMessage): boolean { const forwardedProto = firstHeaderValue(req.headers?.['x-forwarded-proto']); From 3cc939975e8a5b262db9221f17676caa02967cef Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:57:31 -0400 Subject: [PATCH 3/4] refactor(core): rename the Secure cookie check to shouldUseSecureCookies --- .../faustwp-core/src/server/auth/cookie.ts | 6 +++--- .../tests/server/auth/cookie.test.ts | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/faustwp-core/src/server/auth/cookie.ts b/packages/faustwp-core/src/server/auth/cookie.ts index 327401d92..7cf700376 100644 --- a/packages/faustwp-core/src/server/auth/cookie.ts +++ b/packages/faustwp-core/src/server/auth/cookie.ts @@ -27,7 +27,7 @@ function firstHeaderValue(value?: string | string[]): string { * See https://bugs.webkit.org/show_bug.cgi?id=218980. Remove this exception * once supported versions of Safari treat loopback hosts as secure. */ -export function isSecureCookieRequest(req: IncomingMessage): boolean { +export function shouldUseSecureCookies(req: IncomingMessage): boolean { const forwardedProto = firstHeaderValue(req.headers?.['x-forwarded-proto']); const encrypted = (req.socket as { encrypted?: boolean } | undefined) ?.encrypted; @@ -49,14 +49,14 @@ export class Cookies { /** * Whether cookies for this request should use the Secure attribute. See - * `isSecureCookieRequest`. + * `shouldUseSecureCookies`. */ public readonly secure: boolean; constructor(req: IncomingMessage, res?: ServerResponse) { this.request = req; this.response = res; - this.secure = isSecureCookieRequest(req); + this.secure = shouldUseSecureCookies(req); this.cookies = parseCookie(this.request.headers.cookie || ''); } diff --git a/packages/faustwp-core/tests/server/auth/cookie.test.ts b/packages/faustwp-core/tests/server/auth/cookie.test.ts index 45922ab82..d6fa03670 100644 --- a/packages/faustwp-core/tests/server/auth/cookie.test.ts +++ b/packages/faustwp-core/tests/server/auth/cookie.test.ts @@ -2,7 +2,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import { stringifySetCookie } from 'cookie'; import { Cookies, - isSecureCookieRequest, + shouldUseSecureCookies, } from '../../../src/server/auth/cookie'; import { base64Encode } from '../../../src/utils'; @@ -279,7 +279,7 @@ describe('server/auth/cookie', () => { }); }); - describe('isSecureCookieRequest', () => { + describe('shouldUseSecureCookies', () => { const request = ( headers: Record, encrypted?: boolean, @@ -292,24 +292,24 @@ describe('server/auth/cookie', () => { ['[::1]:3000'], ['app.localhost:3000'], ])('is false for plain HTTP to loopback host %s', (host) => { - expect(isSecureCookieRequest(request({ host }))).toBe(false); + expect(shouldUseSecureCookies(request({ host }))).toBe(false); }); test('is true for plain HTTP to a non-loopback host', () => { - expect(isSecureCookieRequest(request({ host: 'example.com' }))).toBe( + expect(shouldUseSecureCookies(request({ host: 'example.com' }))).toBe( true, ); - expect(isSecureCookieRequest(request({ host: 'mylocalhost.com' }))).toBe( + expect(shouldUseSecureCookies(request({ host: 'mylocalhost.com' }))).toBe( true, ); }); test('is true for HTTPS to localhost', () => { expect( - isSecureCookieRequest(request({ host: 'localhost:3000' }, true)), + shouldUseSecureCookies(request({ host: 'localhost:3000' }, true)), ).toBe(true); expect( - isSecureCookieRequest( + shouldUseSecureCookies( request({ host: 'localhost:3000', 'x-forwarded-proto': 'https' }), ), ).toBe(true); @@ -317,7 +317,7 @@ describe('server/auth/cookie', () => { test('uses the forwarded host behind a proxy', () => { expect( - isSecureCookieRequest( + shouldUseSecureCookies( request({ host: 'localhost:3000', 'x-forwarded-host': 'example.com', @@ -328,7 +328,7 @@ describe('server/auth/cookie', () => { }); test('is true when the host is unknown', () => { - expect(isSecureCookieRequest({ headers: {} } as IncomingMessage)).toBe( + expect(shouldUseSecureCookies({ headers: {} } as IncomingMessage)).toBe( true, ); }); From dd4fce4215772b10678555dd54e857235e15defc Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 10:01:22 -0400 Subject: [PATCH 4/4] fix(core): always use Secure cookies in production builds --- packages/faustwp-core/src/server/auth/cookie.ts | 10 ++++++++-- .../faustwp-core/tests/server/auth/cookie.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/faustwp-core/src/server/auth/cookie.ts b/packages/faustwp-core/src/server/auth/cookie.ts index 7cf700376..38cf3d7df 100644 --- a/packages/faustwp-core/src/server/auth/cookie.ts +++ b/packages/faustwp-core/src/server/auth/cookie.ts @@ -21,13 +21,19 @@ function firstHeaderValue(value?: string | string[]): string { * Whether cookies set in response to this request should use the Secure * attribute. * - * Secure is only left off for plain HTTP requests to a loopback host, such as - * http://localhost during development. Safari discards Secure cookies there, + * Secure is only left off outside production builds, for plain HTTP requests + * to a loopback host such as http://localhost. Safari discards Secure cookies there, * unlike other browsers, which loses the login session right after logging in. * See https://bugs.webkit.org/show_bug.cgi?id=218980. Remove this exception * once supported versions of Safari treat loopback hosts as secure. */ export function shouldUseSecureCookies(req: IncomingMessage): boolean { + // Production always uses Secure, so a proxy that forwards a loopback host + // without x-forwarded-proto can't turn it off on a real site. + if (process.env.NODE_ENV === 'production') { + return true; + } + const forwardedProto = firstHeaderValue(req.headers?.['x-forwarded-proto']); const encrypted = (req.socket as { encrypted?: boolean } | undefined) ?.encrypted; diff --git a/packages/faustwp-core/tests/server/auth/cookie.test.ts b/packages/faustwp-core/tests/server/auth/cookie.test.ts index d6fa03670..f85c55be8 100644 --- a/packages/faustwp-core/tests/server/auth/cookie.test.ts +++ b/packages/faustwp-core/tests/server/auth/cookie.test.ts @@ -327,6 +327,18 @@ describe('server/auth/cookie', () => { ).toBe(true); }); + test('is always true in production builds', () => { + const nodeEnv = process.env.NODE_ENV; + (process.env as Record).NODE_ENV = 'production'; + try { + expect( + shouldUseSecureCookies(request({ host: 'localhost:3000' })), + ).toBe(true); + } finally { + (process.env as Record).NODE_ENV = nodeEnv; + } + }); + test('is true when the host is unknown', () => { expect(shouldUseSecureCookies({ headers: {} } as IncomingMessage)).toBe( true,