From 7d61c583f41f845590075e0377e3aec790a62d8c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 17:13:31 +0100 Subject: [PATCH 1/5] feat: make cookie names configurable with prefix setting Add cookie.prefix setting (default "ep_") that gets prepended to all cookie names set by Etherpad. This prevents conflicts with other applications on the same domain that use generic cookie names like "sessionID" or "token". Affected cookies: token, sessionID, language, prefs/prefsHttp, express_sid. The prefix is passed to the client via clientVars.cookiePrefix in the bootstrap templates so it's available before the handshake. Server-side cookie reads fall back to unprefixed names for backward compatibility during migration. Fixes #664 Co-Authored-By: Claude Opus 4.6 (1M context) --- settings.json.template | 7 +++++++ src/node/handler/PadMessageHandler.ts | 1 + src/node/hooks/express.ts | 2 +- src/node/hooks/express/importexport.ts | 6 +++++- src/node/hooks/express/tokenTransfer.ts | 6 ++++-- src/node/padaccess.ts | 7 ++++++- src/node/utils/Settings.ts | 2 ++ src/static/js/pad.ts | 10 ++++++---- src/static/js/pad_cookie.ts | 3 ++- src/static/js/pad_editor.ts | 3 ++- src/static/js/timeslider.ts | 5 +++-- src/templates/padBootstrap.js | 1 + src/templates/padViteBootstrap.js | 1 + src/templates/timeSliderBootstrap.js | 1 + 14 files changed, 42 insertions(+), 13 deletions(-) diff --git a/settings.json.template b/settings.json.template index d8e42fe60b6..b00b64eb415 100644 --- a/settings.json.template +++ b/settings.json.template @@ -385,6 +385,13 @@ * Settings controlling the session cookie issued by Etherpad. */ "cookie": { + /* + * Prefix for all cookie names set by Etherpad. Change this if Etherpad's + * cookie names conflict with those of another application on the same domain. + * Default: "ep_" + */ + // "prefix": "ep_", + /* * How often (in milliseconds) the key used to sign the express_sid cookie * should be rotated. Long rotation intervals reduce signature verification diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 080ab044a0c..74db46e100b 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -1058,6 +1058,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp, }, initialChangesets: [], // FIXME: REMOVE THIS SHIT, + cookiePrefix: settings.cookie.prefix, mode: process.env.NODE_ENV }; diff --git a/src/node/hooks/express.ts b/src/node/hooks/express.ts index fb24cbfe69b..554ff6a561a 100644 --- a/src/node/hooks/express.ts +++ b/src/node/hooks/express.ts @@ -209,7 +209,7 @@ exports.restartServer = async () => { saveUninitialized: false, // Set the cookie name to a javascript identifier compatible string. Makes code handling it // cleaner :) - name: 'express_sid', + name: `${settings.cookie.prefix}express_sid`, cookie: { maxAge: sessionLifetime || undefined, // Convert 0 to null. sameSite: settings.cookie.sameSite, diff --git a/src/node/hooks/express/importexport.ts b/src/node/hooks/express/importexport.ts index 7f93568448f..d3d40664afd 100644 --- a/src/node/hooks/express/importexport.ts +++ b/src/node/hooks/express/importexport.ts @@ -76,8 +76,12 @@ exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Functio (async () => { // @ts-ignore const {session: {user} = {}} = req; + const p = settings.cookie.prefix; const {accessStatus, authorID: authorId} = await securityManager.checkAccess( - req.params.pad, req.cookies.sessionID, req.cookies.token, user); + req.params.pad, + req.cookies[`${p}sessionID`] || req.cookies.sessionID, + req.cookies[`${p}token`] || req.cookies.token, + user); if (accessStatus !== 'grant' || !webaccess.userCanModify(req.params.pad, req)) { return res.status(403).send('Forbidden'); } diff --git a/src/node/hooks/express/tokenTransfer.ts b/src/node/hooks/express/tokenTransfer.ts index 9a6bb25f181..5a0ccbe01e9 100644 --- a/src/node/hooks/express/tokenTransfer.ts +++ b/src/node/hooks/express/tokenTransfer.ts @@ -1,6 +1,7 @@ import {ArgsExpressType} from "../../types/ArgsExpressType"; const db = require('../../db/DB'); import crypto from 'crypto' +import settings from '../../utils/Settings'; type TokenTransferRequest = { @@ -38,8 +39,9 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => const token = await db.get(`${tokenTransferKey}:${id}`) - res.cookie('token', tokenData.token, {path: '/', maxAge: 1000*60*60*24*365}); - res.cookie('prefsHttp', tokenData.prefsHttp, {path: '/', maxAge: 1000*60*60*24*365}); + const p = settings.cookie.prefix; + res.cookie(`${p}token`, tokenData.token, {path: '/', maxAge: 1000*60*60*24*365}); + res.cookie(`${p}prefsHttp`, tokenData.prefsHttp, {path: '/', maxAge: 1000*60*60*24*365}); res.send(token); }) } diff --git a/src/node/padaccess.ts b/src/node/padaccess.ts index ce3cf9ddd71..6db856fb674 100644 --- a/src/node/padaccess.ts +++ b/src/node/padaccess.ts @@ -1,11 +1,16 @@ 'use strict'; const securityManager = require('./db/SecurityManager'); +import settings from './utils/Settings'; // checks for padAccess module.exports = async (req: { params?: any; cookies?: any; session?: any; }, res: { status: (arg0: number) => { (): any; new(): any; send: { (arg0: string): void; new(): any; }; }; }) => { const {session: {user} = {}} = req; + const p = settings.cookie.prefix; const accessObj = await securityManager.checkAccess( - req.params.pad, req.cookies.sessionID, req.cookies.token, user); + req.params.pad, + req.cookies[`${p}sessionID`] || req.cookies.sessionID, + req.cookies[`${p}token`] || req.cookies.token, + user); if (accessObj.accessStatus === 'grant') { // there is access, continue diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index ee09141a7fb..aca72412ac8 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -252,6 +252,7 @@ export type SettingsType = { trustProxy: boolean, cookie: { keyRotationInterval: number, + prefix: string, sameSite: boolean | "lax" | "strict" | "none" | undefined, sessionLifetime: number, sessionRefreshInterval: number, @@ -530,6 +531,7 @@ const settings: SettingsType = { */ cookie: { keyRotationInterval: 1 * 24 * 60 * 60 * 1000, + prefix: 'ep_', sameSite: 'lax', sessionLifetime: 10 * 24 * 60 * 60 * 1000, sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000, diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 27447b31d42..367e5e0887c 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -144,7 +144,8 @@ const getParameters = [ callback: (val) => { console.log('Val is', val) html10n.localize([val, 'en']); - Cookies.set('language', val); + const prefix = (window as any).clientVars?.cookiePrefix || ''; + Cookies.set(`${prefix}language`, val); }, }, ]; @@ -183,10 +184,11 @@ const sendClientReady = (isReconnect) => { document.title = `${padId.replace(/_+/g, ' ')} | ${title}`; } - let token = Cookies.get('token'); + const cp = (window as any).clientVars?.cookiePrefix || ''; + let token = Cookies.get(`${cp}token`) || Cookies.get('token'); if (token == null || !padutils.isValidAuthorToken(token)) { token = padutils.generateAuthorToken(); - Cookies.set('token', token, {expires: 60}); + Cookies.set(`${cp}token`, token, {expires: 60}); } // If known, propagate the display name and color to the server in the CLIENT_READY message. This @@ -203,7 +205,7 @@ const sendClientReady = (isReconnect) => { component: 'pad', type: 'CLIENT_READY', padId, - sessionID: Cookies.get('sessionID'), + sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'), token, userInfo, }; diff --git a/src/static/js/pad_cookie.ts b/src/static/js/pad_cookie.ts index bc624e96250..7859a7c5e3e 100644 --- a/src/static/js/pad_cookie.ts +++ b/src/static/js/pad_cookie.ts @@ -21,7 +21,8 @@ import {Cookies} from "./pad_utils"; exports.padcookie = new class { constructor() { - this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'; + const prefix = (window as any).clientVars?.cookiePrefix || ''; + this.cookieName_ = prefix + (window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'); } init() { diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index e416f27186e..e85c2a08419 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -142,7 +142,8 @@ const padeditor = (() => { }); $('#languagemenu').val(html10n.getLanguage()); $('#languagemenu').on('change', () => { - Cookies.set('language', $('#languagemenu').val()); + const cp = (window as any).clientVars?.cookiePrefix || ''; + Cookies.set(`${cp}language`, $('#languagemenu').val()); html10n.localize([$('#languagemenu').val(), 'en']); if ($('select').niceSelect) { $('select').niceSelect('update'); diff --git a/src/static/js/timeslider.ts b/src/static/js/timeslider.ts index befc4f8b7b4..c339fdcf011 100644 --- a/src/static/js/timeslider.ts +++ b/src/static/js/timeslider.ts @@ -48,10 +48,11 @@ const init = () => { document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`; // ensure we have a token - token = Cookies.get('token'); + const cp = (window as any).clientVars?.cookiePrefix || ''; + token = Cookies.get(`${cp}token`) || Cookies.get('token'); if (token == null) { token = `t.${randomString()}`; - Cookies.set('token', token, {expires: 60}); + Cookies.set(`${cp}token`, token, {expires: 60}); } socket = socketio.connect(exports.baseURL, '/', {query: {padId}}); diff --git a/src/templates/padBootstrap.js b/src/templates/padBootstrap.js index caa3692a00a..fce449de49f 100644 --- a/src/templates/padBootstrap.js +++ b/src/templates/padBootstrap.js @@ -7,6 +7,7 @@ // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server // sends the CLIENT_VARS message. randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>, + cookiePrefix: <%-JSON.stringify(settings.cookie.prefix)%>, }; // Allow other frames to access this frame's modules. diff --git a/src/templates/padViteBootstrap.js b/src/templates/padViteBootstrap.js index 05f759077de..461fbb592e1 100644 --- a/src/templates/padViteBootstrap.js +++ b/src/templates/padViteBootstrap.js @@ -5,6 +5,7 @@ window.clientVars = { // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server // sends the CLIENT_VARS message. randomVersionString: "7a7bdbad", + cookiePrefix: "ep_", }; (async () => { diff --git a/src/templates/timeSliderBootstrap.js b/src/templates/timeSliderBootstrap.js index e3138cfbdd6..b0cbe3e7e4f 100644 --- a/src/templates/timeSliderBootstrap.js +++ b/src/templates/timeSliderBootstrap.js @@ -3,6 +3,7 @@ window.clientVars = { // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the // server sends the CLIENT_VARS message. randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>, + cookiePrefix: <%-JSON.stringify(settings.cookie.prefix)%>, }; let BroadcastSlider; From 146c2e176eb70fb9c029a587d6eb1c5e567741fb Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 17:15:47 +0100 Subject: [PATCH 2/5] fix: default cookie prefix to empty string for backward compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the default to "ep_" would invalidate all existing sessions on upgrade since express-session only looks for the configured cookie name. Default to "" (no prefix) so upgrades are non-breaking — users opt-in to prefixed names by setting cookie.prefix in settings.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- settings.json.template | 6 +++--- src/node/utils/Settings.ts | 2 +- src/templates/padViteBootstrap.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/settings.json.template b/settings.json.template index b00b64eb415..8c242a3117c 100644 --- a/settings.json.template +++ b/settings.json.template @@ -386,9 +386,9 @@ */ "cookie": { /* - * Prefix for all cookie names set by Etherpad. Change this if Etherpad's - * cookie names conflict with those of another application on the same domain. - * Default: "ep_" + * Prefix for all cookie names set by Etherpad. Set this to "ep_" or similar + * if Etherpad's cookie names (token, sessionID, etc.) conflict with those + * of another application on the same domain. Default: "" (no prefix). */ // "prefix": "ep_", diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index aca72412ac8..e0670e0a334 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -531,7 +531,7 @@ const settings: SettingsType = { */ cookie: { keyRotationInterval: 1 * 24 * 60 * 60 * 1000, - prefix: 'ep_', + prefix: '', sameSite: 'lax', sessionLifetime: 10 * 24 * 60 * 60 * 1000, sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000, diff --git a/src/templates/padViteBootstrap.js b/src/templates/padViteBootstrap.js index 461fbb592e1..43a794f2784 100644 --- a/src/templates/padViteBootstrap.js +++ b/src/templates/padViteBootstrap.js @@ -5,7 +5,7 @@ window.clientVars = { // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server // sends the CLIENT_VARS message. randomVersionString: "7a7bdbad", - cookiePrefix: "ep_", + cookiePrefix: "", }; (async () => { From fb66edc25cef5b6fa022b77632db6b915fe3903c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 17:47:00 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20Qodo=20review=20=E2=80=94?= =?UTF-8?q?=20cookie=20prefix=20migration=20and=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - l10n.ts: Read prefixed language cookie with fallback to unprefixed - welcome.ts: Use cookiePrefix for token transfer reads - timeslider.ts: Use prefix for sessionID in socket messages - pad_cookie.ts: Fall back to unprefixed prefs cookie for migration - indexBootstrap.js: Pass cookiePrefix via clientVars to welcome page - specialpages.ts: Pass settings to indexBootstrap template Co-Authored-By: Claude Opus 4.6 (1M context) --- src/node/hooks/express/specialpages.ts | 1 + src/static/js/l10n.ts | 4 +++- src/static/js/pad_cookie.ts | 7 ++++++- src/static/js/timeslider.ts | 5 +++-- src/static/js/welcome.ts | 6 ++++-- src/templates/indexBootstrap.js | 1 + 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 1f4ce33025f..ab4db301355 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -278,6 +278,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c }) const indexString = eejs.require('ep_etherpad-lite/templates/indexBootstrap.js', { + settings, }) const timeSliderString = eejs.require('ep_etherpad-lite/templates/timeSliderBootstrap.js', { diff --git a/src/static/js/l10n.ts b/src/static/js/l10n.ts index 929708b4066..ca8eceb99c9 100644 --- a/src/static/js/l10n.ts +++ b/src/static/js/l10n.ts @@ -3,7 +3,9 @@ import html10n from '../js/vendors/html10n'; // Set language for l10n let regexpLang: string | undefined; -let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/); +const cp = (window as any).clientVars?.cookiePrefix || ''; +let language = document.cookie.match(new RegExp(`${cp}language=((\\w{2,3})(-\\w+)?)`)) + || document.cookie.match(/language=((\w{2,3})(-\w+)?)/); if (language) regexpLang = language[1]; html10n.mt.bind('indexed', () => { diff --git a/src/static/js/pad_cookie.ts b/src/static/js/pad_cookie.ts index 7859a7c5e3e..0231a246655 100644 --- a/src/static/js/pad_cookie.ts +++ b/src/static/js/pad_cookie.ts @@ -44,7 +44,12 @@ exports.padcookie = new class { readPrefs_() { try { - const json = Cookies.get(this.cookieName_); + let json = Cookies.get(this.cookieName_); + // Fall back to unprefixed cookie for migration + if (json == null) { + const unprefixed = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'; + if (unprefixed !== this.cookieName_) json = Cookies.get(unprefixed); + } if (json == null) return null; return JSON.parse(json); } catch (e) { diff --git a/src/static/js/timeslider.ts b/src/static/js/timeslider.ts index c339fdcf011..25ec36f2b6e 100644 --- a/src/static/js/timeslider.ts +++ b/src/static/js/timeslider.ts @@ -33,6 +33,7 @@ import padutils from './pad_utils' const socketio = require('./socketio'); import html10n from '../js/vendors/html10n' let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider; +let cp = ''; const init = () => { padutils.setupGlobalExceptionHandler(); @@ -48,7 +49,7 @@ const init = () => { document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`; // ensure we have a token - const cp = (window as any).clientVars?.cookiePrefix || ''; + cp = (window as any).clientVars?.cookiePrefix || ''; token = Cookies.get(`${cp}token`) || Cookies.get('token'); if (token == null) { token = `t.${randomString()}`; @@ -102,7 +103,7 @@ const sendSocketMsg = (type, data) => { data, padId, token, - sessionID: Cookies.get('sessionID'), + sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'), }); }; diff --git a/src/static/js/welcome.ts b/src/static/js/welcome.ts index dacaed7bfd9..f4e87427d90 100644 --- a/src/static/js/welcome.ts +++ b/src/static/js/welcome.ts @@ -9,6 +9,8 @@ function getCookie(name: string) { } +const cp = (window as any).clientVars?.cookiePrefix || ''; + function handleTransferOfSession() { const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement; @@ -25,8 +27,8 @@ function handleTransferOfSession() { "Content-Type": "application/json" }, body: JSON.stringify({ - prefsHttp: getCookie('prefsHttp'), - token: getCookie('token'), + prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'), + token: getCookie(`${cp}token`) || getCookie('token'), }) }) diff --git a/src/templates/indexBootstrap.js b/src/templates/indexBootstrap.js index 6836a460a03..d8c2f8b989c 100644 --- a/src/templates/indexBootstrap.js +++ b/src/templates/indexBootstrap.js @@ -1,5 +1,6 @@ (async () => { + window.clientVars = { cookiePrefix: <%-JSON.stringify(settings.cookie.prefix)%> }; window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; require('ep_etherpad-lite/static/js/l10n') require('ep_etherpad-lite/static/js/index') From ef25e7546672033205e59abf00a9bd951dad72ea Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 18:06:24 +0100 Subject: [PATCH 4/5] fix: escape regex metacharacters in cookie prefix, document Vite hardcode - l10n.ts: Escape special regex characters in cookiePrefix before using it in RegExp constructor to prevent runtime errors - padViteBootstrap.js: Add comment noting the hardcoded prefix is dev-only and must match settings.json Co-Authored-By: Claude Opus 4.6 (1M context) --- src/static/js/l10n.ts | 2 +- src/templates/padViteBootstrap.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/static/js/l10n.ts b/src/static/js/l10n.ts index ca8eceb99c9..7e11adea620 100644 --- a/src/static/js/l10n.ts +++ b/src/static/js/l10n.ts @@ -3,7 +3,7 @@ import html10n from '../js/vendors/html10n'; // Set language for l10n let regexpLang: string | undefined; -const cp = (window as any).clientVars?.cookiePrefix || ''; +const cp = ((window as any).clientVars?.cookiePrefix || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); let language = document.cookie.match(new RegExp(`${cp}language=((\\w{2,3})(-\\w+)?)`)) || document.cookie.match(/language=((\w{2,3})(-\w+)?)/); if (language) regexpLang = language[1]; diff --git a/src/templates/padViteBootstrap.js b/src/templates/padViteBootstrap.js index 43a794f2784..42b342821a6 100644 --- a/src/templates/padViteBootstrap.js +++ b/src/templates/padViteBootstrap.js @@ -5,6 +5,8 @@ window.clientVars = { // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server // sends the CLIENT_VARS message. randomVersionString: "7a7bdbad", + // Must match cookie.prefix in settings.json (default: ""). + // This file is only used in Vite dev mode and is not template-processed. cookiePrefix: "", }; From 22a0c6d98cd4e6d1eb6d97e7714d3a389973aee3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 21:19:05 +0100 Subject: [PATCH 5/5] security: validate cookie prefix to prevent header injection Reject cookie.prefix values containing characters outside [a-zA-Z0-9_-] to prevent HTTP header injection via crafted cookie names (e.g., \r\n sequences). Falls back to empty prefix with an error log. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/node/utils/Settings.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index e0670e0a334..1124fdebbc7 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -1066,6 +1066,13 @@ export const reloadSettings = () => { 'use automatic key rotation instead (see the cookie.keyRotationInterval setting).'); } + // Validate cookie prefix to prevent header injection via cookie names + if (settings.cookie.prefix && !/^[a-zA-Z0-9_-]*$/.test(settings.cookie.prefix)) { + logger.error(`cookie.prefix "${settings.cookie.prefix}" contains invalid characters. ` + + 'Only alphanumeric characters, hyphens, and underscores are allowed. Using empty prefix.'); + settings.cookie.prefix = ''; + } + if (settings.dbType === 'dirty') { const dirtyWarning = 'DirtyDB is used. This is not recommended for production.'; if (!settings.suppressErrorsInPadText) {