mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 04:06:37 +02:00
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:
parent
f0b84cc1d0
commit
474918a881
@ -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
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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);
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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'),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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'),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user