feat: make cookie names configurable with prefix setting (#7450)

* 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) <noreply@anthropic.com>

* fix: default cookie prefix to empty string for backward compatibility

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) <noreply@anthropic.com>

* fix: address Qodo review — cookie prefix migration and fallbacks

- 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-05 01:58:29 +01:00 committed by GitHub
parent f0b84cc1d0
commit 474918a881
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 68 additions and 18 deletions

View File

@ -385,6 +385,13 @@
* Settings controlling the session cookie issued by Etherpad.
*/
"cookie": {
/*
* 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_",
/*
* How often (in milliseconds) the key used to sign the express_sid cookie
* should be rotated. Long rotation intervals reduce signature verification

View File

@ -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
};

View File

@ -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,

View File

@ -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');
}

View File

@ -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', {

View File

@ -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);
})
}

View File

@ -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

View File

@ -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: '',
sameSite: 'lax',
sessionLifetime: 10 * 24 * 60 * 60 * 1000,
sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000,
@ -1064,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) {

View File

@ -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 || '').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];
html10n.mt.bind('indexed', () => {

View File

@ -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,
};

View File

@ -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() {
@ -43,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) {

View File

@ -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');

View File

@ -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,10 +49,11 @@ const init = () => {
document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`;
// ensure we have a token
token = Cookies.get('token');
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}});
@ -101,7 +103,7 @@ const sendSocketMsg = (type, data) => {
data,
padId,
token,
sessionID: Cookies.get('sessionID'),
sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'),
});
};

View File

@ -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'),
})
})

View File

@ -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')

View File

@ -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.

View File

@ -5,6 +5,9 @@ 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: "",
};
(async () => {

View File

@ -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;