mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
* 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:
parent
72917f4457
commit
21e1ae2fa3
6 changed files with 166 additions and 18 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
106
src/tests/backend/specs/sessionIdCookie.ts
Normal file
106
src/tests/backend/specs/sessionIdCookie.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue