-
-
Notifications
You must be signed in to change notification settings - Fork 3k
security(oidc): stop shipping a hardcoded cookie key and reflecting all CORS origins #8070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JohnMcLear
wants to merge
2
commits into
develop
Choose a base branch
from
security/oidc-provider-hardening
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| }); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Template comment misattached
🐞 Bug⚙ MaintainabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools