security: allow integrator sessionID cookie to be HttpOnly (#7045) (#7755)

* security: allow integrator sessionID cookie to be HttpOnly (#7045)

The integrator-set sessionID cookie was forced to be non-HttpOnly because
Etherpad's own client JS read it via document.cookie and forwarded it in
the socket.io CLIENT_READY payload, exposing it to XSS.

Mirror the GDPR PR3 author-token migration: read sessionID from the
socket.io handshake's Cookie header in PadMessageHandler.handleClientReady,
falling back to the legacy message-level field with a one-time deprecation
warning per socket. Drop the client-side Cookies.get('sessionID') reads in
pad.ts and timeslider.ts so the field is no longer sent by current clients.

Existing integrators that set sessionID without HttpOnly keep working
unchanged; the field on the message becomes optional and integrators
should now mark the cookie HttpOnly; Secure; SameSite=Lax.

Closes #7045

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): treat undecodable handshake cookies as absent (Qodo #7755)

decodeURIComponent() throws URIError on malformed values like `%ZZ`. The
unguarded call in PadMessageHandler.handleClientReady's readCookie() let
a single bad cookie abort CLIENT_READY for that socket, allowing
unauthenticated peers to spam server error logs and lock themselves out
of pads.

Catch URIError and treat the value as absent so the legacy message-level
field still serves as a fallback. Other error classes still propagate.
Add a backend test that asserts a `sessionID=%ZZ` cookie no longer
aborts the handshake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-15 19:44:55 +01:00 committed by GitHub
parent 72917f4457
commit 21e1ae2fa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 166 additions and 18 deletions

View file

@ -15,4 +15,4 @@ Etherpad HTTP API clients may make use (if they choose so) to send another cooki
| Name | Sample value | Domain | Usage description | | Name | Sample value | Domain | Usage description |
|-----------|------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-----------|------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session | | sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID is set as a cookie by the integrator and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Since [#7045](https://github.com/ether/etherpad/issues/7045) Etherpad reads this cookie server-side from the socket.io handshake, so integrators **should** set it as `HttpOnly; Secure; SameSite=Lax` to mitigate XSS. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session |

View file

@ -85,9 +85,12 @@ exports.socketio = () => {
* - auth: Object with the following properties copied from the client's CLIENT_READY message: * - auth: Object with the following properties copied from the client's CLIENT_READY message:
* - padID: Pad ID requested by the user. Unlike the padId property described below, this * - padID: Pad ID requested by the user. Unlike the padId property described below, this
* may be a read-only pad ID. * may be a read-only pad ID.
* - sessionID: Copied from the client's sessionID cookie, which should be the value * - sessionID: The value returned from the createSession() HTTP API, normally set as
* returned from the createSession() HTTP API. This will be null/undefined if * the `sessionID` cookie by the integrator. Read from the socket.io handshake's
* createSession() isn't used or the portal doesn't set the sessionID cookie. * Cookie header (so the cookie can be HttpOnly issue #7045) and falls back to a
* deprecated `sessionID` field on the CLIENT_READY message for legacy clients.
* This will be null/undefined if createSession() isn't used or the integrator
* doesn't set the sessionID cookie.
* - token: User-supplied token. * - token: User-supplied token.
* - author: The user's author ID. * - author: The user's author ID.
* - padId: The real (not read-only) ID of the pad. * - padId: The real (not read-only) ID of the pad.
@ -395,12 +398,31 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
// once so the migration is visible in logs. The socket.io handshake does // once so the migration is visible in logs. The socket.io handshake does
// not run cookie-parser, so pull the cookie directly from the Cookie // not run cookie-parser, so pull the cookie directly from the Cookie
// header. // header.
//
// The same applies to the integrator-set `sessionID` cookie (issue #7045):
// historically the client read it from `document.cookie`, which forced the
// cookie to be non-HttpOnly and exposed it to XSS. Now we read it from the
// handshake Cookie header so integrators can set it `HttpOnly`.
const cookiePrefix = settings.cookie?.prefix || ''; const cookiePrefix = settings.cookie?.prefix || '';
const cookieHeader: string = socket.request?.headers?.cookie || ''; const cookieHeader: string = socket.request?.headers?.cookie || '';
const cookieName = `${cookiePrefix}token`; const readCookie = (name: string): string | null => {
const cookieMatch = cookieHeader.split(/;\s*/).find( const match = cookieHeader.split(/;\s*/).find(
(c) => c.split('=')[0] === cookieName); (c) => c.split('=')[0] === name);
const cookieToken = cookieMatch ? decodeURIComponent(cookieMatch.split('=').slice(1).join('=')) : null; if (!match) return null;
const raw = match.split('=').slice(1).join('=');
// A malformed value (e.g. `name=%ZZ`) makes decodeURIComponent throw
// URIError. Without this guard a single bad cookie aborts CLIENT_READY,
// letting an unauthenticated peer spam server error logs and block
// itself from joining (flagged by Qodo on #7755). Treat undecodable
// values as absent.
try {
return decodeURIComponent(raw);
} catch (err) {
if (err instanceof URIError) return null;
throw err;
}
};
const cookieToken = readCookie(`${cookiePrefix}token`);
const legacyToken = typeof message.token === 'string' ? message.token : null; const legacyToken = typeof message.token === 'string' ? message.token : null;
const resolvedToken = cookieToken || legacyToken; const resolvedToken = cookieToken || legacyToken;
if (!cookieToken && legacyToken && !thisSession.legacyTokenWarned) { if (!cookieToken && legacyToken && !thisSession.legacyTokenWarned) {
@ -410,11 +432,23 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
'See docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md'); 'See docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md');
thisSession.legacyTokenWarned = true; thisSession.legacyTokenWarned = true;
} }
const cookieSessionID =
readCookie(`${cookiePrefix}sessionID`) || readCookie('sessionID');
const legacySessionID =
typeof message.sessionID === 'string' ? message.sessionID : null;
const resolvedSessionID = cookieSessionID || legacySessionID;
if (!cookieSessionID && legacySessionID && !thisSession.legacySessionIdWarned) {
messageLogger.warn(
'client sent sessionID via CLIENT_READY message; integrators should ' +
'set the sessionID cookie as HttpOnly (issue #7045). The in-message ' +
'field is deprecated and will be removed in a future release.');
thisSession.legacySessionIdWarned = true;
}
// Remember this information since we won't have the cookie in further socket.io messages. This // Remember this information since we won't have the cookie in further socket.io messages. This
// information will be used to check if the sessionId of this connection is still valid since it // information will be used to check if the sessionId of this connection is still valid since it
// could have been deleted by the API. // could have been deleted by the API.
thisSession.auth = { thisSession.auth = {
sessionID: message.sessionID, sessionID: resolvedSessionID,
padID: message.padId, padID: message.padId,
token: resolvedToken, token: resolvedToken,
}; };

View file

@ -305,10 +305,10 @@ const sendClientReady = (isReconnect) => {
document.title = `${padId.replace(/_+/g, ' ')} | ${title}`; document.title = `${padId.replace(/_+/g, ' ')} | ${title}`;
} }
const cp = (window as any).clientVars?.cookiePrefix || '';
// The author token lives in an HttpOnly cookie set by the server (GDPR PR3 / // The author token lives in an HttpOnly cookie set by the server (GDPR PR3 /
// ether/etherpad#6701). The browser never reads or writes it; the server // ether/etherpad#6701). The integrator-set `sessionID` cookie can also be
// reads the cookie from the socket.io handshake inside handleClientReady. // HttpOnly now (issue #7045). The browser never reads or writes either; the
// server reads them from the socket.io handshake inside handleClientReady.
// If known, propagate the display name and color to the server in the CLIENT_READY message. This // If known, propagate the display name and color to the server in the CLIENT_READY message. This
// allows the server to include the values in its reply CLIENT_VARS message (which avoids // allows the server to include the values in its reply CLIENT_VARS message (which avoids
@ -320,11 +320,13 @@ const sendClientReady = (isReconnect) => {
name: params.get('userName'), name: params.get('userName'),
}; };
// The integrator-set `sessionID` cookie is read server-side from the
// socket.io handshake (issue #7045) so it can be HttpOnly. We no longer
// forward it via the CLIENT_READY payload.
const msg: any = { const msg: any = {
component: 'pad', component: 'pad',
type: 'CLIENT_READY', type: 'CLIENT_READY',
padId, padId,
sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'),
userInfo, userInfo,
}; };
const overrides = getMyViewOverrides(); const overrides = getMyViewOverrides();

View file

@ -157,13 +157,15 @@ const init = () => {
}; };
// sends a message over the socket // sends a message over the socket
// The integrator-set `sessionID` cookie is consumed server-side from the
// socket.io handshake (issue #7045). It does not need to ride on every
// message; the server only reads it during CLIENT_READY.
const sendSocketMsg = (type, data) => { const sendSocketMsg = (type, data) => {
socket.emit("message", { socket.emit("message", {
component: 'pad', // FIXME: Remove this stupidity! component: 'pad', // FIXME: Remove this stupidity!
type, type,
data, data,
padId, padId,
sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'),
}); });
}; };

View file

@ -190,8 +190,10 @@ export type ClientReadyMessage = {
type: 'CLIENT_READY', type: 'CLIENT_READY',
component: string, component: string,
padId: string, padId: string,
sessionID: string, /** @deprecated since #7045 — read server-side from the HttpOnly cookie. */
token: string, sessionID?: string,
/** @deprecated since GDPR PR3 — read server-side from the HttpOnly cookie. */
token?: string,
userInfo: UserInfo, userInfo: UserInfo,
padSettingsDefaults?: PadOption, padSettingsDefaults?: PadOption,
reconnect?: boolean reconnect?: boolean
@ -329,8 +331,10 @@ export type SocketClientReadyMessage = {
type: string type: string
component: string component: string
padId: string padId: string
sessionID: string /** @deprecated since #7045 — read server-side from the HttpOnly cookie. */
token: string sessionID?: string
/** @deprecated since GDPR PR3 — read server-side from the HttpOnly cookie. */
token?: string
userInfo: { userInfo: {
colorId: string|null colorId: string|null
name: string|null name: string|null

View file

@ -0,0 +1,106 @@
'use strict';
/**
* Regression test for https://github.com/ether/etherpad/issues/7045.
*
* Before the fix, Etherpad's client-side JavaScript read the integrator-set
* `sessionID` cookie via `document.cookie` and forwarded it in the socket.io
* CLIENT_READY payload. That forced integrators to mark the cookie as
* non-HttpOnly, exposing it to XSS.
*
* The fix moves the read to the server: `PadMessageHandler.handleClientReady`
* now pulls `sessionID` out of the socket.io handshake's `Cookie` header so
* integrators can mark the cookie `HttpOnly; Secure; SameSite=Lax`.
*
* The legacy message-level `sessionID` field is still accepted as a one-release
* fallback, with a one-time warning per socket.
*/
const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const {sessioninfos} = require('../../../node/handler/PadMessageHandler');
import settings from '../../../node/utils/Settings';
const io = require('socket.io-client');
const cookiePrefix = () => settings.cookie?.prefix || '';
describe(__filename, function () {
this.timeout(30000);
let socket: any;
before(async function () { await common.init(); });
beforeEach(async function () {
assert(socket == null);
});
afterEach(async function () {
if (socket) socket.close();
socket = null;
if (await padManager.doesPadExist('pad')) {
const pad = await padManager.getPad('pad');
await pad.remove();
}
});
const connectWithCookie = async (cookieHeader: string) => {
const s = io(`${common.baseUrl}/`, {
forceNew: true,
query: {cookie: cookieHeader, padId: 'pad'},
});
await common.waitForSocketEvent(s, 'connect', 5000);
return s;
};
const sendClientReady = async (s: any, message: any) => {
s.emit('message', {
component: 'pad',
type: 'CLIENT_READY',
padId: 'pad',
...message,
});
const reply: any = await common.waitForSocketEvent(s, 'message', 5000);
assert.equal(reply.type, 'CLIENT_VARS');
};
it('reads sessionID from the handshake Cookie header', async function () {
socket = await connectWithCookie('sessionID=s.aaaaaaaaaaaaaaaa');
await sendClientReady(socket, {});
assert.equal(sessioninfos[socket.id].auth.sessionID, 's.aaaaaaaaaaaaaaaa');
});
it('honours the configured cookie prefix', async function () {
socket = await connectWithCookie(`${cookiePrefix()}sessionID=s.bbbbbbbbbbbbbbbb`);
await sendClientReady(socket, {});
assert.equal(sessioninfos[socket.id].auth.sessionID, 's.bbbbbbbbbbbbbbbb');
});
it('falls back to message.sessionID for legacy clients (no cookie)', async function () {
socket = await connectWithCookie('');
await sendClientReady(socket, {sessionID: 's.cccccccccccccccc'});
assert.equal(sessioninfos[socket.id].auth.sessionID, 's.cccccccccccccccc');
});
it('prefers the cookie over the legacy message field', async function () {
socket = await connectWithCookie('sessionID=s.dddddddddddddddd');
await sendClientReady(socket, {sessionID: 's.eeeeeeeeeeeeeeee'});
assert.equal(sessioninfos[socket.id].auth.sessionID, 's.dddddddddddddddd');
});
it('records null when no sessionID is provided', async function () {
socket = await connectWithCookie('');
await sendClientReady(socket, {});
assert.equal(sessioninfos[socket.id].auth.sessionID, null);
});
it('treats a malformed (undecodable) cookie as absent rather than aborting', async function () {
// %ZZ is not a valid percent-encoded sequence; decodeURIComponent() throws
// URIError. Without the guard this would tear down CLIENT_READY and let
// any client log-spam the server (Qodo bug on #7755). The handshake must
// still complete and fall through to the message-field fallback.
socket = await connectWithCookie('sessionID=%ZZ');
await sendClientReady(socket, {sessionID: 's.ffffffffffffffff'});
assert.equal(sessioninfos[socket.id].auth.sessionID, 's.ffffffffffffffff');
});
});