diff --git a/settings.json.template b/settings.json.template index d8e42fe60..8c242a311 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. 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 diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 56266772d..6c2216503 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 fb24cbfe6..554ff6a56 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 7f9356844..d3d40664a 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/specialpages.ts b/src/node/hooks/express/specialpages.ts index 1f4ce3302..ab4db3013 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/node/hooks/express/tokenTransfer.ts b/src/node/hooks/express/tokenTransfer.ts index 9a6bb25f1..5a0ccbe01 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 ce3cf9ddd..6db856fb6 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 ee09141a7..1124fdebb 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: '', 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) { diff --git a/src/static/js/l10n.ts b/src/static/js/l10n.ts index 929708b40..7e11adea6 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 || '').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', () => { diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 27447b31d..367e5e088 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 bc624e962..0231a2466 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() { @@ -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) { diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index e416f2718..e85c2a084 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 befc4f8b7..25ec36f2b 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,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'), }); }; diff --git a/src/static/js/welcome.ts b/src/static/js/welcome.ts index dacaed7bf..f4e87427d 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 6836a460a..d8c2f8b98 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') diff --git a/src/templates/padBootstrap.js b/src/templates/padBootstrap.js index caa3692a0..fce449de4 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 05f759077..42b342821 100644 --- a/src/templates/padViteBootstrap.js +++ b/src/templates/padViteBootstrap.js @@ -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 () => { diff --git a/src/templates/timeSliderBootstrap.js b/src/templates/timeSliderBootstrap.js index e3138cfbd..b0cbe3e7e 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;