Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion packages/faustwp-core/src/server/auth/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,60 @@ 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 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;
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;

private response?: ServerResponse;

private cookies: Record<string, string | undefined> = {};

/**
* Whether cookies for this request should use the Secure attribute. See
* `shouldUseSecureCookies`.
*/
public readonly secure: boolean;

constructor(req: IncomingMessage, res?: ServerResponse) {
this.request = req;
this.response = res;
this.secure = shouldUseSecureCookies(req);

this.cookies = parseCookie(this.request.headers.cookie || '');
}
Expand Down Expand Up @@ -72,7 +116,7 @@ export class Cookies {
stringifySetCookie(key, '', {
path: '/',
sameSite: 'strict',
secure: true,
secure: this.secure,
httpOnly: true,
expires: new Date(0),
}),
Expand Down
2 changes: 1 addition & 1 deletion packages/faustwp-core/src/server/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class OAuth {
maxAge,
path: '/',
sameSite: 'strict',
secure: true,
secure: this.cookies.secure,
httpOnly: true,
});
}
Expand Down
93 changes: 92 additions & 1 deletion packages/faustwp-core/tests/server/auth/cookie.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { IncomingMessage, ServerResponse } from 'http';
import { stringifySetCookie } from 'cookie';
import { Cookies } from '../../../src/server/auth/cookie';
import {
Cookies,
shouldUseSecureCookies,
} from '../../../src/server/auth/cookie';
import { base64Encode } from '../../../src/utils';

describe('server/auth/cookie', () => {
Expand Down Expand Up @@ -275,4 +278,92 @@ describe('server/auth/cookie', () => {
expect(cookies.getCookie('no_res_key')).toBeUndefined();
});
});

describe('shouldUseSecureCookies', () => {
const request = (
headers: Record<string, string | string[]>,
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(shouldUseSecureCookies(request({ host }))).toBe(false);
});

test('is true for plain HTTP to a non-loopback host', () => {
expect(shouldUseSecureCookies(request({ host: 'example.com' }))).toBe(
true,
);
expect(shouldUseSecureCookies(request({ host: 'mylocalhost.com' }))).toBe(
true,
);
});

test('is true for HTTPS to localhost', () => {
expect(
shouldUseSecureCookies(request({ host: 'localhost:3000' }, true)),
).toBe(true);
expect(
shouldUseSecureCookies(
request({ host: 'localhost:3000', 'x-forwarded-proto': 'https' }),
),
).toBe(true);
});

test('uses the forwarded host behind a proxy', () => {
expect(
shouldUseSecureCookies(
request({
host: 'localhost:3000',
'x-forwarded-host': 'example.com',
'x-forwarded-proto': 'http',
}),
),
).toBe(true);
});

test('is always true in production builds', () => {
const nodeEnv = process.env.NODE_ENV;
(process.env as Record<string, string>).NODE_ENV = 'production';
try {
expect(
shouldUseSecureCookies(request({ host: 'localhost:3000' })),
).toBe(true);
} finally {
(process.env as Record<string, string | undefined>).NODE_ENV = nodeEnv;
}
});

test('is true when the host is unknown', () => {
expect(shouldUseSecureCookies({ 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),
}),
);
});
});
});
21 changes: 21 additions & 0 deletions packages/faustwp-core/tests/server/auth/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading