Skip to content

security(oidc): stop shipping a hardcoded cookie key and reflecting all CORS origins#8070

Open
JohnMcLear wants to merge 2 commits into
developfrom
security/oidc-provider-hardening
Open

security(oidc): stop shipping a hardcoded cookie key and reflecting all CORS origins#8070
JohnMcLear wants to merge 2 commits into
developfrom
security/oidc-provider-hardening

Conversation

@JohnMcLear

Copy link
Copy Markdown
Member

Fixes two issues in the embedded OIDC provider (src/node/security/OAuth2Provider.ts), reported privately by meifukun (credit requested as meifukun, https://github.com/meifukun). Verified against 3.3.2 @ 9a6109b; both lines are unchanged on current develop.

1. Hardcoded OIDC cookie signing key (['oidc'])

The provider signed its interaction/session/grant cookies (_interaction, _interaction_resume, _grant, _session) with the committed literal key oidc, so anyone with the public source could compute valid .sig cookie signatures and defeat the provider's cookie-integrity boundary.

Fix: resolveOidcCookieKeys() prefers an operator-supplied settings.sso.cookieKeys (ordered array for rotation), otherwise derives a secret key from the persisted session secret (SESSIONKEY.txt) via a domain-separated SHA-256 — stable across restarts and horizontally-scaled pods, never committed to source. Falls back to an ephemeral random key when no session secret exists.

2. clientBasedCORS returned true for every origin

The token/userinfo endpoints reflected any Origin into Access-Control-Allow-Origin, letting unregistered origins read responses that use non-cookie credentials (Authorization headers, POST-body client secrets).

Fix: isOriginAllowedForOidcClient() allows a CORS origin only when it matches the origin of one of the client's registered redirect URIs.

Tests / verification

  • New OidcProviderSecurity.ts — 14 unit tests over both pure helpers (stable derivation, no hardcoded key, exact-origin matching, look-alike/scheme-mismatch rejection).
  • Verified end-to-end against a real oidc-provider@9.9.1 instance: cookies.keys accepted, Client.redirectUris read correctly, attacker origin blocked.
  • tsc --noEmit clean.

A private GHSA draft tracks both issues.

🤖 Generated with Claude Code

…ing 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>
Copilot AI review requested due to automatic review settings July 25, 2026 15:06
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Harden embedded OIDC provider cookie signing and CORS origin validation

🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Derive OIDC cookie signing keys from operator config or persisted session secret.
• Restrict OIDC CORS to origins matching registered client redirect URI origins.
• Add unit tests and document optional settings.sso.cookieKeys rotation support.
Diagram

graph TD
  S["settings.json.template / env"] --> ST["Settings.ts"] --> O["OAuth2Provider.ts"] --> P{{"oidc-provider"}}
  SK["SESSIONKEY.txt"] --> ST --> H["OidcProviderSecurity.ts"] --> O
  P --> C["clientBasedCORS"] --> H
  R["Client redirect URIs"] --> H
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fail fast if no stable cookie key source
  • ➕ Avoids implicit per-process random key that can break in-flight OIDC interactions on restart
  • ➕ Forces operators of HA deployments to explicitly configure key material
  • ➖ Potentially breaking change for single-node dev installs without SESSIONKEY.txt present
  • ➖ Requires additional operator setup even when Etherpad already has a session secret
2. Explicit CORS allowlist in settings (in addition to redirect URIs)
  • ➕ Supports deployments where redirect URIs are broader than intended CORS readers
  • ➕ Gives operators direct control without relying on client metadata shape/normalization
  • ➖ Extra configuration surface area to document and validate
  • ➖ Risk of misconfiguration that reintroduces permissive CORS behavior
3. Use HKDF for cookie key derivation instead of plain SHA-256(label||secret)
  • ➕ More standard KDF construction with clearer security properties and extension options (salt/info)
  • ➕ Easier to evolve to multi-key derivation scheme later
  • ➖ Adds complexity and potentially a dependency or more code
  • ➖ Current domain-separated hash derivation is likely sufficient for this use case

Recommendation: Current approach is solid: prefer operator-supplied rotation keys, otherwise derive a stable secret from the persisted session secret with domain separation, and tighten CORS to exact redirect-URI origins. The main follow-up worth considering is adding an explicit warning log (or optional fail-fast mode) when falling back to an ephemeral random cookie key, because that mode is operationally surprising in multi-pod or restart-heavy environments.

Files changed (5) +218 / -7

Bug fix (2) +108 / -7
OAuth2Provider.tsRemove hardcoded cookie key and restrict OIDC CORS to registered origins +24/-7

Remove hardcoded cookie key and restrict OIDC CORS to registered origins

• Stops configuring oidc-provider with the committed 'cookies.keys: ['oidc']' and instead resolves keys at provider construction time via 'resolveOidcCookieKeys()'. Replaces unconditional 'clientBasedCORS: true' with a redirect-URI-origin-based allow check via 'isOriginAllowedForOidcClient()'.

src/node/security/OAuth2Provider.ts

OidcProviderSecurity.tsAdd pure helpers for OIDC cookie key resolution and CORS origin checks +84/-0

Add pure helpers for OIDC cookie key resolution and CORS origin checks

• Introduces 'resolveOidcCookieKeys()' to prefer operator-provided keys, otherwise derive a stable key from the session secret, with a random fallback when unavailable. Adds 'isOriginAllowedForOidcClient()' to allow only exact origin matches against the client's registered redirect URIs.

src/node/security/OidcProviderSecurity.ts

Tests (1) +98 / -0
OidcProviderSecurity.tsAdd unit tests for cookie key derivation and CORS allowlisting +98/-0

Add unit tests for cookie key derivation and CORS allowlisting

• Adds backend unit tests covering cookie key precedence/derivation behavior and exact-origin matching against redirect URIs. Includes negative cases for look-alike domains, scheme mismatch, and missing inputs.

src/tests/backend/specs/OidcProviderSecurity.ts

Documentation (1) +8 / -0
settings.json.templateDocument optional 'sso.cookieKeys' for OIDC cookie signing/rotation +8/-0

Document optional 'sso.cookieKeys' for OIDC cookie signing/rotation

• Adds commented documentation for configuring an ordered 'cookieKeys' array for the embedded OIDC provider. Explains default behavior (derivation from SESSIONKEY.txt) and how to rotate keys.

settings.json.template

Other (1) +4 / -0
Settings.tsExtend SSO settings type with optional 'cookieKeys' +4/-0

Extend SSO settings type with optional 'cookieKeys'

• Updates the 'SettingsType' definition to include optional 'sso.cookieKeys?: string[]' and documents intended rotation semantics. This makes the new configuration option part of the typed settings contract.

src/node/utils/Settings.ts

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 25, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. OIDC cookie key unstable ✓ Resolved 🐞 Bug ☼ Reliability
Description
expressCreateServer() derives oidc-provider cookie signing keys from settings.sessionKey, but
Settings defaults enable cookie key rotation and keep sessionKey null if SESSIONKEY.txt is absent,
so resolveOidcCookieKeys() falls back to a per-process random key. This invalidates in-flight OIDC
interaction/session cookies on restart and prevents horizontally-scaled pods from sharing valid OIDC
cookies unless sso.cookieKeys is explicitly set or SESSIONKEY.txt is provisioned.
Code

src/node/security/OAuth2Provider.ts[R102-109]

+        cookies: {
+            // Secret, operator/deployment-stable signing keys — never a
+            // committed literal. See resolveOidcCookieKeys().
+            keys: resolveOidcCookieKeys({
+                cookieKeys: (settings.sso as {cookieKeys?: unknown}).cookieKeys,
+                sessionKey: settings.sessionKey,
+            }),
+        },
Evidence
OAuth2Provider passes settings.sessionKey into resolveOidcCookieKeys(). Settings defaults set
sessionKey to null and enable cookie rotation by default, and the Settings loader intentionally
does not create SESSIONKEY.txt when rotation is enabled—so settings.sessionKey remains null on
fresh installs and the OIDC cookie key becomes random. Express still gets a stable cookie-signing
secret via SecretRotator under rotation, but that secret is not used for OIDC cookie signing.

src/node/security/OAuth2Provider.ts[100-109]
src/node/utils/Settings.ts[732-761]
src/node/utils/Settings.ts[1328-1341]
src/node/hooks/express.ts[186-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`resolveOidcCookieKeys()` falls back to a random key whenever `settings.sessionKey` is null/empty and `settings.sso.cookieKeys` is unset/invalid. With the repo’s default cookie rotation settings, `settings.sessionKey` commonly remains null on fresh installs (no SESSIONKEY.txt created), making the embedded OIDC provider’s cookie-signing key change on every restart (and differ per pod).
### Issue Context
The Express stack already has a stable cookie-signing secret when rotation is enabled (via `SecretRotator`), but `OAuth2Provider.ts` does not reuse it.
### Fix Focus Areas
- src/node/security/OAuth2Provider.ts[100-109]
- src/node/utils/Settings.ts[736-761]
- src/node/utils/Settings.ts[1328-1341]
- src/node/hooks/express.ts[186-201]
### Implementation direction
- Prefer a stable, deployment-wide secret source even when `settings.sessionKey` is null:
- Option A (recommended): Create a dedicated `SecretRotator` namespace for OIDC cookie keys (e.g. `oidcCookieSecrets`) stored in the DB, and pass its `secrets` array to `cookies.keys` (supports rotation + multi-pod stability).
- Option B: Plumb the Express cookie signing secret(s) (the `secret`/`secretRotator.secrets`) to `OAuth2Provider.ts` and use that as the fallback input to `resolveOidcCookieKeys()`.
- Option C: Generate and persist an OIDC cookie secret to disk (separate from deprecated SESSIONKEY) and derive from that.
- Ensure key rotation semantics are preserved (first key signs; older keys still verify).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Template comment misattached 🐞 Bug ⚙ Maintainability
Description
settings.json.template documents sso.cookieKeys but does not include an actual cookieKeys property,
so the comment block becomes the leading comment for the next real key (sso.clients) in the Admin
UI’s template-comment extraction. This can mis-document sso.clients and make cookieKeys harder to
discover/configure correctly.
Code

settings.json.template[R967-977]

 "sso": {
   "issuer": "${SSO_ISSUER:http://localhost:9001}",
+      /*
+       * Optional signing keys for the embedded OIDC provider's cookies. When
+       * omitted, 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": [
Evidence
The template places a cookieKeys doc block immediately before clients without a cookieKeys key.
The Admin UI builds a path→comment map by walking the parsed template tree and extracting leading
adjacent comments for each property node, so the cookieKeys block will be recorded against the
sso.clients path.

settings.json.template[967-983]
admin/src/components/settings/templateComments.ts[17-37]
admin/src/components/settings/comments.ts[33-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `cookieKeys` documentation block is inserted above `sso.clients`, but there is no actual `"cookieKeys": ...` key in the template. The Admin UI’s comment extractor associates leading comments with the next property node, so this block will be shown for `sso.clients`.
### Issue Context
Admin settings comments are extracted from `settings.json.template` using `jsonc-parser` node offsets and adjacent comment lookup.
### Fix Focus Areas
- settings.json.template[967-977]
- admin/src/components/settings/templateComments.ts[17-37]
- admin/src/components/settings/comments.ts[33-70]
### Implementation direction
- Add a real `"cookieKeys"` property in `settings.json.template` (for example: `"cookieKeys": ["${OIDC_COOKIE_KEY:}"],`) and place the comment immediately above it.
- Keep `resolveOidcCookieKeys()`’s existing filtering so an empty env var value safely falls through to the derived/persisted behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/node/security/OAuth2Provider.ts
Comment thread settings.json.template
Comment on lines 967 to 977
"sso": {
"issuer": "${SSO_ISSUER:http://localhost:9001}",
/*
* Optional signing keys for the embedded OIDC provider's cookies. When
* omitted, 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": [

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Informational

2. Template comment misattached 🐞 Bug ⚙ Maintainability

settings.json.template documents sso.cookieKeys but does not include an actual cookieKeys property,
so the comment block becomes the leading comment for the next real key (sso.clients) in the Admin
UI’s template-comment extraction. This can mis-document sso.clients and make cookieKeys harder to
discover/configure correctly.
Agent Prompt
### Issue description
The `cookieKeys` documentation block is inserted above `sso.clients`, but there is no actual `"cookieKeys": ...` key in the template. The Admin UI’s comment extractor associates leading comments with the next property node, so this block will be shown for `sso.clients`.

### Issue Context
Admin settings comments are extracted from `settings.json.template` using `jsonc-parser` node offsets and adjacent comment lookup.

### Fix Focus Areas
- settings.json.template[967-977]
- admin/src/components/settings/templateComments.ts[17-37]
- admin/src/components/settings/comments.ts[33-70]

### Implementation direction
- Add a real `"cookieKeys"` property in `settings.json.template` (for example: `"cookieKeys": ["${OIDC_COOKIE_KEY:}"],`) and place the comment immediately above it.
- Keep `resolveOidcCookieKeys()`’s existing filtering so an empty env var value safely falls through to the derived/persisted behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens Etherpad’s embedded OIDC provider by removing a hardcoded cookie-signing key and by restricting OIDC CORS responses to only origins that match a client’s registered redirect URI origins.

Changes:

  • Introduces resolveOidcCookieKeys() to derive OIDC cookie-signing keys from operator-provided settings or from the persisted session secret, avoiding committed key material.
  • Introduces isOriginAllowedForOidcClient() to replace unconditional CORS origin reflection with exact origin matching against registered redirect URIs.
  • Adds backend unit tests to cover both helpers and updates settings types/template docs to expose the new sso.cookieKeys option.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/node/security/OAuth2Provider.ts Removes hardcoded cookie key and replaces permissive CORS callback with helper-based allow-listing.
src/node/security/OidcProviderSecurity.ts Adds dependency-light, unit-testable helpers for cookie key resolution and CORS origin checks.
src/tests/backend/specs/OidcProviderSecurity.ts Adds unit tests covering cookie key derivation behavior and exact-origin CORS allow logic.
src/node/utils/Settings.ts Extends SettingsType with optional sso.cookieKeys for operator-supplied key rotation.
settings.json.template Documents optional sso.cookieKeys configuration in the default template.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +31 to +35
* any pod — without ever committing key material to source.
* 3. As a last resort (no session key available), an ephemeral per-process
* random key. The integrity boundary holds, but interactions won't survive
* a restart or span multiple pods.
*/
…ault 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>
Copilot AI review requested due to automatic review settings July 25, 2026 15:38
@JohnMcLear

Copy link
Copy Markdown
Member Author

Thanks @qodo-free-for-open-source-projects — good catch. Confirmed: with default cookie.keyRotationInterval/sessionLifetime set, settings.sessionKey stays null, so the fallback would hand the provider a per-process random key (breaking OIDC cookies on restart / across pods). Fixed in the latest commit: added an oidcCookieSecrets SecretRotator (same DB-backed mechanism as the Express session cookies) as the default key source, with an integration test proving the default config yields stable rotator secrets rather than a random key.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment on lines 304 to 311
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[]
},
Comment thread settings.json.template
Comment on lines +969 to 977
/*
* Optional signing keys for the embedded OIDC provider's cookies. When
* omitted, 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": [
@JohnMcLear
JohnMcLear requested a review from SamTV12345 July 26, 2026 07:10
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.

2 participants