mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-19 01:24:19 +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
|
|
@ -85,9 +85,12 @@ exports.socketio = () => {
|
|||
* - 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
|
||||
* may be a read-only pad ID.
|
||||
* - sessionID: Copied from the client's sessionID cookie, which should be the value
|
||||
* returned from the createSession() HTTP API. This will be null/undefined if
|
||||
* createSession() isn't used or the portal doesn't set the sessionID cookie.
|
||||
* - sessionID: The value returned from the createSession() HTTP API, normally set as
|
||||
* the `sessionID` cookie by the integrator. Read from the socket.io handshake's
|
||||
* 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.
|
||||
* - author: The user's author ID.
|
||||
* - 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
|
||||
// not run cookie-parser, so pull the cookie directly from the Cookie
|
||||
// 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 cookieHeader: string = socket.request?.headers?.cookie || '';
|
||||
const cookieName = `${cookiePrefix}token`;
|
||||
const cookieMatch = cookieHeader.split(/;\s*/).find(
|
||||
(c) => c.split('=')[0] === cookieName);
|
||||
const cookieToken = cookieMatch ? decodeURIComponent(cookieMatch.split('=').slice(1).join('=')) : null;
|
||||
const readCookie = (name: string): string | null => {
|
||||
const match = cookieHeader.split(/;\s*/).find(
|
||||
(c) => c.split('=')[0] === name);
|
||||
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 resolvedToken = cookieToken || legacyToken;
|
||||
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');
|
||||
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
|
||||
// information will be used to check if the sessionId of this connection is still valid since it
|
||||
// could have been deleted by the API.
|
||||
thisSession.auth = {
|
||||
sessionID: message.sessionID,
|
||||
sessionID: resolvedSessionID,
|
||||
padID: message.padId,
|
||||
token: resolvedToken,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue