mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-08-01 22:45:09 +00:00
security(oidc): stop shipping a hardcoded cookie key and reflecting all CORS origins (#8070)
* security(oidc): stop shipping a hardcoded OIDC cookie key and reflecting all CORS origins The embedded OIDC provider signed its interaction/session/grant cookies with the committed literal key `['oidc']`, so anyone with the public source could forge valid `.sig` cookies (defeating the provider's cookie-integrity boundary), and `clientBasedCORS` returned `true` for every origin, reflecting arbitrary `Origin` values into `Access-Control-Allow-Origin` on the token/userinfo endpoints. Adds OidcProviderSecurity.ts with two unit-tested pure helpers: - resolveOidcCookieKeys(): prefers an operator-supplied `settings.sso.cookieKeys` (ordered array for rotation), otherwise derives a secret key from the persisted session secret via a domain-separated SHA-256 — stable across restarts and multi-pod, never committed to source; falls back to an ephemeral random key. - isOriginAllowedForOidcClient(): allows a CORS origin only when it matches the origin of one of the client's registered redirect URIs. Verified end-to-end against a real oidc-provider@9.9.1 instance (cookies.keys accepted, Client.redirectUris read correctly, attacker origin blocked). Reported privately by meifukun. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * security(oidc): use DB-backed SecretRotator for cookie keys under default rotation config Addresses Qodo review on #8070. With Etherpad's default cookie settings (keyRotationInterval + sessionLifetime set), key rotation is enabled and `settings.sessionKey` stays null unless SESSIONKEY.txt is provisioned, so the previous fallback handed the OIDC provider a per-process random key — breaking in-flight OIDC cookies on restart and across horizontally-scaled pods on a default install. resolveOidcCookieKeys() now takes an optional rotatedSecrets array (priority: operator cookieKeys > rotated secrets > session-key derivation > random) and returns it by reference so a live rotation propagates to keygrip. OAuth2Provider.expressCreateServer() creates a dedicated `oidcCookieSecrets` SecretRotator — the same DB-backed mechanism the Express session cookies use — when the operator hasn't pinned settings.sso.cookieKeys and rotation is enabled. Adds unit tests for the rotatedSecrets priority/by-reference behavior and an integration test proving the default config yields stable DB-backed secrets rather than a random key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(settings): give sso.cookieKeys a real key in the template The doc block sat above `sso.clients` with no `cookieKeys` property, so the admin UI's template-comment extractor (which attaches leading comments to the next property node) would have shown it as documentation for `clients`. An unset OIDC_COOKIE_KEY yields [""], which resolveOidcCookieKeys() filters out, so the derived/rotated key path is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
This commit is contained in:
parent
9706e08260
commit
682cb65625
6 changed files with 341 additions and 7 deletions
|
|
@ -966,6 +966,14 @@
|
|||
|
||||
"sso": {
|
||||
"issuer": "${SSO_ISSUER:http://localhost:9001}",
|
||||
/*
|
||||
* Signing keys for the embedded OIDC provider's cookies. Leave empty and
|
||||
* Etherpad derives a secret key from the persisted session secret
|
||||
* (SESSIONKEY.txt), which is stable across restarts and shared across
|
||||
* horizontally-scaled pods. Set an explicit ordered array to rotate: the
|
||||
* first key signs, the rest are still accepted for verify.
|
||||
*/
|
||||
"cookieKeys": ["${OIDC_COOKIE_KEY:}"],
|
||||
"clients": [
|
||||
{
|
||||
"client_id": "${ADMIN_CLIENT:admin_client}",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import {format} from 'url'
|
|||
import {ParsedUrlQuery} from "node:querystring";
|
||||
import {MapArrayType} from "../types/MapType";
|
||||
import crypto from "node:crypto";
|
||||
import {resolveOidcCookieKeys, isOriginAllowedForOidcClient} from "./OidcProviderSecurity";
|
||||
import SecretRotator from "./SecretRotator";
|
||||
|
||||
// Held at module scope so the rotator (and its refresh timer) is not garbage
|
||||
// collected for the lifetime of the process, mirroring express.ts.
|
||||
let oidcCookieSecretRotator: SecretRotator | null = null;
|
||||
|
||||
// Small fixed delay applied to every failed interactive login, mirroring
|
||||
// webaccess.authnFailureDelayMs, so failures take a consistent amount of time.
|
||||
|
|
@ -68,9 +74,13 @@ const configuration: Configuration = {
|
|||
profile: ['name'],
|
||||
admin: ['admin']
|
||||
},
|
||||
cookies: {
|
||||
keys: ['oidc'],
|
||||
},
|
||||
// NOTE: cookies.keys is deliberately NOT set here. A committed literal key
|
||||
// (historically ['oidc']) lets anyone with the public source forge valid
|
||||
// OIDC provider `.sig` cookies. The real key material is resolved at
|
||||
// provider-construction time in expressCreateServer() via
|
||||
// resolveOidcCookieKeys(), which prefers an operator-supplied
|
||||
// settings.sso.cookieKeys and otherwise derives a secret, stable key from
|
||||
// the persisted session secret. See OidcProviderSecurity.ts.
|
||||
features:{
|
||||
devInteractions: {enabled: false},
|
||||
},
|
||||
|
|
@ -92,8 +102,39 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
|
|||
publicKeyExported = publicKey
|
||||
privateKeyExported = privateKey
|
||||
|
||||
// Resolve the OIDC provider's cookie-signing keys. Never a committed literal.
|
||||
// When the operator hasn't pinned settings.sso.cookieKeys and cookie key
|
||||
// rotation is enabled (the default), reuse the same DB-backed SecretRotator
|
||||
// mechanism as the Express session cookies so the key is stable across
|
||||
// restarts and shared across horizontally-scaled pods. `settings.sessionKey`
|
||||
// is commonly null under default rotation settings, so relying on it alone
|
||||
// would hand the provider an unstable per-process random key.
|
||||
const operatorCookieKeys = (settings.sso as {cookieKeys?: unknown}).cookieKeys;
|
||||
const hasOperatorKeys = Array.isArray(operatorCookieKeys) &&
|
||||
operatorCookieKeys.some((k) => typeof k === 'string' && k.length > 0);
|
||||
const {keyRotationInterval, sessionLifetime} = settings.cookie;
|
||||
let rotatedSecrets: string[] | null = null;
|
||||
if (!hasOperatorKeys && keyRotationInterval && sessionLifetime) {
|
||||
if (oidcCookieSecretRotator == null) {
|
||||
oidcCookieSecretRotator = new SecretRotator(
|
||||
'oidcCookieSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
|
||||
await oidcCookieSecretRotator.start();
|
||||
}
|
||||
rotatedSecrets = oidcCookieSecretRotator.secrets;
|
||||
}
|
||||
|
||||
const oidc = new Provider(settings.sso.issuer, {
|
||||
...configuration, jwks: {
|
||||
...configuration,
|
||||
cookies: {
|
||||
// Secret, deployment-stable signing keys — never a committed literal.
|
||||
// See resolveOidcCookieKeys().
|
||||
keys: resolveOidcCookieKeys({
|
||||
cookieKeys: operatorCookieKeys,
|
||||
rotatedSecrets,
|
||||
sessionKey: settings.sessionKey,
|
||||
}),
|
||||
},
|
||||
jwks: {
|
||||
keys: [
|
||||
privateKeyJWK
|
||||
],
|
||||
|
|
@ -127,9 +168,12 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
|
|||
},
|
||||
jwtResponseModes: {enabled: true},
|
||||
},
|
||||
clientBasedCORS: (ctx, origin, client) => {
|
||||
return true
|
||||
},
|
||||
clientBasedCORS: (ctx, origin, client) =>
|
||||
// Only allow cross-origin reads from an origin registered as one of
|
||||
// the client's redirect URIs. Returning `true` unconditionally
|
||||
// reflected any Origin into Access-Control-Allow-Origin. See
|
||||
// isOriginAllowedForOidcClient().
|
||||
isOriginAllowedForOidcClient(origin, client as {redirectUris?: unknown}),
|
||||
extraParams: [],
|
||||
extraTokenClaims: async (ctx, token) => {
|
||||
if(token.kind === 'AccessToken') {
|
||||
|
|
|
|||
96
src/node/security/OidcProviderSecurity.ts
Normal file
96
src/node/security/OidcProviderSecurity.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Security helpers for the embedded OIDC provider (`OAuth2Provider.ts`).
|
||||
*
|
||||
* Kept in a dependency-light module (only `node:crypto`) so the pure decisions
|
||||
* can be unit-tested without constructing an `oidc-provider` instance.
|
||||
*/
|
||||
|
||||
// Domain-separation label so the derived cookie key is cryptographically
|
||||
// unrelated to any other use of the session secret. The trailing NUL keeps the
|
||||
// label from ambiguously running into the appended secret.
|
||||
export const COOKIE_KEY_DERIVATION_LABEL = 'etherpad-oidc-provider-cookie-signing-key\0';
|
||||
|
||||
/**
|
||||
* Resolve the cookie-signing keys for the embedded OIDC provider.
|
||||
*
|
||||
* oidc-provider signs its short-lived interaction/session/grant cookies
|
||||
* (`_interaction`, `_interaction_resume`, `_grant`, `_session`) with an HMAC
|
||||
* keyed by these values. Shipping a hardcoded key (historically `['oidc']`) let
|
||||
* anyone with the public source forge valid `.sig` cookies, defeating the
|
||||
* provider's cookie-integrity guarantee. Reported by `meifukun`.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. An explicit operator-supplied `settings.sso.cookieKeys` array — use this
|
||||
* for controlled rotation: `[newKey, ...oldKeys]`.
|
||||
* 2. The live secrets array of a DB-backed `SecretRotator` (the same mechanism
|
||||
* the Express session-cookie stack uses). This is the default path: it is
|
||||
* stable across restarts, shared across horizontally-scaled pods via the
|
||||
* database, and rotates automatically. The array is returned BY REFERENCE
|
||||
* so a later in-place rotation propagates to oidc-provider/keygrip without
|
||||
* reconstructing the provider.
|
||||
* 3. A value derived from the persisted Etherpad session secret
|
||||
* (`SESSIONKEY.txt`) via a domain-separated SHA-256 — used when rotation is
|
||||
* disabled but a static session key exists. Stable across restarts/pods,
|
||||
* never committed to source.
|
||||
* 4. As a last resort (no key material at all), an ephemeral per-process
|
||||
* random key. The integrity boundary holds, but interactions won't survive
|
||||
* a restart or span multiple pods.
|
||||
*/
|
||||
export const resolveOidcCookieKeys = (
|
||||
opts: {cookieKeys?: unknown, rotatedSecrets?: string[] | null, sessionKey?: string | null},
|
||||
): string[] => {
|
||||
const {cookieKeys, rotatedSecrets, sessionKey} = opts;
|
||||
|
||||
if (Array.isArray(cookieKeys)) {
|
||||
const usable = cookieKeys.filter((k): k is string => typeof k === 'string' && k.length > 0);
|
||||
if (usable.length > 0) return usable;
|
||||
}
|
||||
|
||||
// Return the rotator's array by reference (do not copy/filter) so in-place
|
||||
// rotation is observed live by keygrip.
|
||||
if (Array.isArray(rotatedSecrets) &&
|
||||
rotatedSecrets.some((k) => typeof k === 'string' && k.length > 0)) {
|
||||
return rotatedSecrets;
|
||||
}
|
||||
|
||||
if (typeof sessionKey === 'string' && sessionKey.length > 0) {
|
||||
const derived = crypto.createHash('sha256')
|
||||
.update(COOKIE_KEY_DERIVATION_LABEL)
|
||||
.update(sessionKey)
|
||||
.digest('hex');
|
||||
return [derived];
|
||||
}
|
||||
|
||||
return [crypto.randomBytes(32).toString('hex')];
|
||||
};
|
||||
|
||||
/**
|
||||
* Origin allow-list decision for the embedded OIDC provider's CORS-enabled
|
||||
* endpoints (`/oidc/token`, `/oidc/me`, ...). oidc-provider invokes
|
||||
* `clientBasedCORS(ctx, origin, client)` for every cross-origin request;
|
||||
* returning `true` unconditionally (the historical behavior) reflected ANY
|
||||
* `Origin` into `Access-Control-Allow-Origin`, letting unregistered origins read
|
||||
* token/userinfo responses that use non-cookie credentials (Authorization
|
||||
* headers, POST-body client credentials). Reported by `meifukun`.
|
||||
*
|
||||
* An origin is allowed only when it exactly matches the origin (scheme + host +
|
||||
* port) of one of the client's registered redirect URIs.
|
||||
*/
|
||||
export const isOriginAllowedForOidcClient = (
|
||||
origin: string | undefined | null,
|
||||
client: {redirectUris?: unknown} | undefined | null,
|
||||
): boolean => {
|
||||
if (!origin || !client) return false;
|
||||
const uris = (client as {redirectUris?: unknown}).redirectUris;
|
||||
if (!Array.isArray(uris)) return false;
|
||||
return uris.some((uri: unknown) => {
|
||||
if (typeof uri !== 'string') return false;
|
||||
try {
|
||||
return new URL(uri).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -304,6 +304,10 @@ export type SettingsType = {
|
|||
sso: {
|
||||
issuer: string,
|
||||
clients?: {client_id: string}[]
|
||||
// Optional operator-supplied signing keys for the embedded OIDC provider's
|
||||
// cookies. When unset, a secret key is derived from the session secret.
|
||||
// Provide an ordered array `[newKey, ...oldKeys]` to rotate.
|
||||
cookieKeys?: string[]
|
||||
},
|
||||
showSettingsInAdminPage: boolean,
|
||||
cleanup: {
|
||||
|
|
|
|||
128
src/tests/backend/specs/OidcProviderSecurity.ts
Normal file
128
src/tests/backend/specs/OidcProviderSecurity.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit coverage for the embedded OIDC provider's cookie-signing key derivation
|
||||
* and CORS origin allow-list. Both were reported by `meifukun`:
|
||||
* - the provider historically signed its cookies with the hardcoded key
|
||||
* `['oidc']`, so anyone with the public source could forge valid `.sig`
|
||||
* cookies;
|
||||
* - `clientBasedCORS` returned `true` for every origin, reflecting arbitrary
|
||||
* `Origin` values into `Access-Control-Allow-Origin`.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import {
|
||||
resolveOidcCookieKeys,
|
||||
isOriginAllowedForOidcClient,
|
||||
} from '../../../node/security/OidcProviderSecurity';
|
||||
|
||||
describe(__filename, function () {
|
||||
describe('resolveOidcCookieKeys', function () {
|
||||
it('never returns the historical hardcoded key', function () {
|
||||
const keys = resolveOidcCookieKeys({sessionKey: 'a-persisted-session-secret'});
|
||||
assert.ok(!keys.includes('oidc'));
|
||||
});
|
||||
|
||||
it('uses operator-supplied cookieKeys when provided', function () {
|
||||
const keys = resolveOidcCookieKeys({cookieKeys: ['k1', 'k2'], sessionKey: 'x'});
|
||||
assert.deepEqual(keys, ['k1', 'k2']);
|
||||
});
|
||||
|
||||
it('ignores empty/invalid entries in cookieKeys and falls through', function () {
|
||||
const keys = resolveOidcCookieKeys({cookieKeys: ['', null as any], sessionKey: 'secret'});
|
||||
assert.equal(keys.length, 1);
|
||||
assert.notEqual(keys[0], 'oidc');
|
||||
assert.ok(keys[0].length >= 32);
|
||||
});
|
||||
|
||||
it('prefers rotated DB-backed secrets over the session-key derivation', function () {
|
||||
const rotated = ['rot-new', 'rot-old'];
|
||||
const keys = resolveOidcCookieKeys({rotatedSecrets: rotated, sessionKey: 'secret'});
|
||||
assert.deepEqual(keys, ['rot-new', 'rot-old']);
|
||||
});
|
||||
|
||||
it('returns the live rotatedSecrets array by reference (so rotation propagates)', function () {
|
||||
// oidc-provider/keygrip holds the array by reference and reads it live on
|
||||
// each sign/verify, so returning the same object means a rotation that
|
||||
// mutates the array in place is picked up without reconstructing the provider.
|
||||
const rotated = ['rot-new'];
|
||||
const keys = resolveOidcCookieKeys({rotatedSecrets: rotated, sessionKey: 'secret'});
|
||||
assert.strictEqual(keys, rotated);
|
||||
});
|
||||
|
||||
it('ignores an empty rotatedSecrets array and falls through', function () {
|
||||
const keys = resolveOidcCookieKeys({rotatedSecrets: [], sessionKey: 'secret'});
|
||||
assert.equal(keys.length, 1);
|
||||
assert.notEqual(keys[0], 'oidc');
|
||||
// fell through to the session-key derivation (stable, deterministic)
|
||||
assert.deepEqual(keys, resolveOidcCookieKeys({sessionKey: 'secret'}));
|
||||
});
|
||||
|
||||
it('lets operator cookieKeys win over rotated secrets', function () {
|
||||
const keys = resolveOidcCookieKeys({
|
||||
cookieKeys: ['operator'], rotatedSecrets: ['rot'], sessionKey: 'secret',
|
||||
});
|
||||
assert.deepEqual(keys, ['operator']);
|
||||
});
|
||||
|
||||
it('derives a stable key from the session secret (survives restart/multi-pod)', function () {
|
||||
const a = resolveOidcCookieKeys({sessionKey: 'secret'});
|
||||
const b = resolveOidcCookieKeys({sessionKey: 'secret'});
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
it('derives different keys for different session secrets', function () {
|
||||
const a = resolveOidcCookieKeys({sessionKey: 'secret-a'});
|
||||
const b = resolveOidcCookieKeys({sessionKey: 'secret-b'});
|
||||
assert.notDeepEqual(a, b);
|
||||
});
|
||||
|
||||
it('does not reuse the raw session secret as the cookie key', function () {
|
||||
const keys = resolveOidcCookieKeys({sessionKey: 'secret'});
|
||||
assert.ok(!keys.includes('secret'));
|
||||
});
|
||||
|
||||
it('falls back to a fresh random key when no session secret exists', function () {
|
||||
const a = resolveOidcCookieKeys({sessionKey: null});
|
||||
const b = resolveOidcCookieKeys({sessionKey: null});
|
||||
assert.equal(a.length, 1);
|
||||
assert.notEqual(a[0], 'oidc');
|
||||
assert.ok(a[0].length >= 32);
|
||||
assert.notDeepEqual(a, b); // random => different each call
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOriginAllowedForOidcClient', function () {
|
||||
const client = {
|
||||
redirectUris: ['https://app.example.com/admin/', 'https://app.example.com/'],
|
||||
};
|
||||
|
||||
it('allows an origin matching a registered redirect URI', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', client), true);
|
||||
});
|
||||
|
||||
it('rejects an unregistered attacker origin', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://evil.attacker.com', client), false);
|
||||
});
|
||||
|
||||
it('rejects a look-alike suffix origin (no substring matching)', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com.evil.com', client), false);
|
||||
});
|
||||
|
||||
it('rejects a scheme mismatch (http vs https)', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('http://app.example.com', client), false);
|
||||
});
|
||||
|
||||
it('rejects when origin is missing', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient(undefined, client), false);
|
||||
});
|
||||
|
||||
it('rejects when client is missing', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', null), false);
|
||||
});
|
||||
|
||||
it('rejects when client has no redirect URIs', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', {}), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
54
src/tests/backend/specs/oidcCookieRotator.ts
Normal file
54
src/tests/backend/specs/oidcCookieRotator.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Integration coverage for the OIDC provider cookie-key source under the
|
||||
* DEFAULT deployment config (cookie key rotation enabled, no SESSIONKEY.txt so
|
||||
* `settings.sessionKey` is null). Without the DB-backed SecretRotator path the
|
||||
* provider would fall back to a per-process random key, breaking OIDC cookies on
|
||||
* restart and across horizontally-scaled pods. This test proves the default
|
||||
* path yields a stable, non-random, DB-backed key.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const SecretRotator = require('../../../node/security/SecretRotator').SecretRotator;
|
||||
import {resolveOidcCookieKeys} from '../../../node/security/OidcProviderSecurity';
|
||||
import settings from '../../../node/utils/Settings';
|
||||
|
||||
describe(__filename, function () {
|
||||
this.timeout(30000);
|
||||
|
||||
before(async function () {
|
||||
// Boots the server, which initialises the database the rotator writes to.
|
||||
await common.init();
|
||||
});
|
||||
|
||||
it('default config yields DB-backed rotator secrets, not a random key', async function () {
|
||||
const {keyRotationInterval, sessionLifetime} = settings.cookie;
|
||||
assert.ok(keyRotationInterval && sessionLifetime,
|
||||
'precondition: cookie key rotation is enabled by default');
|
||||
|
||||
const rotator = new SecretRotator(
|
||||
'oidcCookieSecretsTest', keyRotationInterval, sessionLifetime, settings.sessionKey);
|
||||
await rotator.start();
|
||||
try {
|
||||
assert.ok(Array.isArray(rotator.secrets) && rotator.secrets.length > 0,
|
||||
'rotator produced at least one secret');
|
||||
assert.ok(rotator.secrets.every((s: any) => typeof s === 'string' && s.length > 0),
|
||||
'all rotator secrets are non-empty strings');
|
||||
|
||||
// The provider would select these rotated secrets (by reference) over any
|
||||
// session-key derivation or random fallback.
|
||||
const keys = resolveOidcCookieKeys({
|
||||
cookieKeys: (settings.sso as any).cookieKeys,
|
||||
rotatedSecrets: rotator.secrets,
|
||||
sessionKey: settings.sessionKey,
|
||||
});
|
||||
assert.strictEqual(keys, rotator.secrets, 'resolver returns the live rotator array');
|
||||
assert.ok(!keys.includes('oidc'), 'never the historical hardcoded key');
|
||||
} finally {
|
||||
// stop the refresh timer so it does not keep the event loop alive
|
||||
if (typeof rotator.stop === 'function') await rotator.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue