From f123e37ed95ad37323427327df6cca93efae621c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:22:48 +0100 Subject: [PATCH 01/10] docs: PR3 GDPR anonymous identity hardening design spec --- ...026-04-19-gdpr-pr3-anon-identity-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md diff --git a/docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md b/docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md new file mode 100644 index 00000000000..8278f77cc96 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md @@ -0,0 +1,181 @@ +# PR3 — GDPR Anonymous Identity Hardening + +Third of five GDPR PRs (ether/etherpad#6701). Today's anonymous author +token is generated and set by client JavaScript, which forces it to be a +non-`HttpOnly` cookie (any JS on the page — including XSS — can read it +and impersonate the author). This PR moves token issuance and the +authoritative cookie-set to the server so the cookie can be +`HttpOnly; Secure; SameSite=Lax` end-to-end, while staying +fully backwards-compatible for one release. + +## Audit summary + +- The author token is stored in the `ep_token` cookie (prefix `${cp}`) + and generated client-side: `src/static/js/pad.ts:191-195` reads an + existing cookie, otherwise calls `padutils.generateAuthorToken()` and + writes a fresh cookie with `expires: 60` (days). +- Server-side mapping: `AuthorManager.getAuthor4Token()` (via + `SecurityManager.checkAccess`) persists `token2author:` → an + `authorID`. The raw plaintext token is the DB key. +- Cookie attributes set in `pad_utils.ts:515-516` on the client's + `Cookies` instance: `sameSite: 'Lax'` (or `'None'` in third-party + iframes), `secure: `. **`httpOnly` is not set** — JS + (including XSS payloads) can read and replay the token. +- The CLIENT_READY socket message sends `token` in the payload; + `SecurityManager.checkAccess` validates it via + `padutils.isValidAuthorToken()` and resolves it to an authorID. +- No IP-based identity fallback exists today (confirmed while writing + PR2 — `clientVars.clientIp` was hardcoded `'127.0.0.1'` and is + removed in PR2). + +The author-token cookie is a bearer credential that grants write access +(and, with PR1 shipped, bypasses the creator-cookie check for deletion) +to every pad this browser has ever touched. An `HttpOnly` cookie +eliminates the biggest class of token theft (XSS / third-party script +read). + +## Goals + +- Author-token cookies are set by the Etherpad server on the pad HTTP + response, marked `HttpOnly; Secure (on HTTPS); SameSite=Lax` (or + `None` in a third-party iframe context where the existing override + applies). +- The client never reads or writes the author-token cookie. It also + stops sending `token` in CLIENT_READY — the server reads the cookie + from the socket.io handshake request instead. +- Existing sessions with a client-set token continue to work: the + server honours a `token` field in CLIENT_READY when no `ep_token` + cookie is present, migrates it to an HttpOnly cookie on the next + HTTP response, and emits a one-time deprecation WARN. +- IP-based identity fallback stays off — document it so plugins can't + accidentally re-introduce it. + +## Non-goals + +- Rotating or revoking tokens. Token lifecycle still "set once, valid + until expiry". Revocation ties into author erasure (PR5). +- Changing the `token2author:` DB key shape. Moving to hashed + storage is worthwhile but orthogonal — slated for PR5 alongside + author erasure. +- Moving the session / read-only cookies. Only the author token is in + scope. +- Expanding deletion rights. PR1 already covered that surface. + +## Design + +### Server-side cookie set + +- New middleware mounted on `/p/:pad` (and the admin-free static pad + HTML responses): if the request carries no `ep_token` cookie (with + the configured prefix), the middleware generates a token in the + existing `t.` format via the existing + `padutils.generateAuthorToken()` helper (shared between client and + server), writes it via `res.cookie()`, and attaches it to + `req.authorToken` for downstream handlers. +- `res.cookie()` options: + ```js + { + httpOnly: true, + secure: req.secure, // true on HTTPS + sameSite: isThirdPartyIframe(req) ? 'none' : 'lax', + maxAge: 60 * 24 * 60 * 60 * 1000, // 60 days — same as today + path: '/', // match current client-set scope + // (`domain` intentionally unset — matches the current cookie) + } + ``` +- `isThirdPartyIframe(req)` reuses the server's existing embed + detection (checks `Sec-Fetch-Site: cross-site` plus referrer + heuristics — already imported in `webaccess.ts` for session cookies). +- The cookie prefix matches `settings.cookie.prefix` so the existing + prefixed-and-unprefixed read logic keeps working. + +### Socket.io handshake reads the cookie + +- `PadMessageHandler.handleClientReady` currently trusts + `message.token`. Change the resolution order to: + + 1. `socket.request.cookies[`${cp}token`]` / `cookies.token` if set — + primary path for PR3 and every new browser. + 2. `message.token` if supplied and a non-empty string — legacy + fallback. When this path is used, emit a one-time warn per author + (“client is still sending token; cookie migration will take + effect on next HTTP response”) and flag `session.legacyToken = + true` so the Express middleware, if hit by this browser, can + rewrite it into an HttpOnly cookie on the next request. + 3. Neither present → refuse (existing error path). + +- Socket.io already parses cookies via `cookie-parser` middleware mounted + before socket.io in `hooks/express.ts`. No extra wiring needed — + `socket.request.cookies` is populated. + +### Client JS stops touching the token + +- Delete the `Cookies.get(cp+'token')`, `generateAuthorToken()`, and + `Cookies.set(cp+'token', …)` block in + `src/static/js/pad.ts:190-195`. +- CLIENT_READY message: drop the `token` field entirely from new + clients. (Server still accepts it from older browsers — see above.) +- Remove unused exports: + - `padutils.isValidAuthorToken` stays (server still validates via + the shared helper). + - `padutils.generateAuthorToken` — keep the helper (server uses it), + but it is no longer called from the browser. + +### IP-identity guardrail + +- Add a one-line comment and a `doc/privacy.md` sentence making + explicit that Etherpad's server-side code never falls back to + `req.ip` for author identity. Already true; document it so a future + commit doesn't silently regress. + +## Testing + +### Backend + +`src/tests/backend/specs/authorTokenCookie.ts`: + +1. GET `/p/` with no cookies — response carries a + `Set-Cookie: token=t.<…>; HttpOnly; SameSite=Lax`, + `Secure` asserted only when the test goes over HTTPS. +2. GET `/p/` **again** with the `token` cookie set + (from the first response) — no new `Set-Cookie` for that name + emitted. Existing value preserved. +3. Socket.io CLIENT_READY with the cookie but no `token` field — + resolves to an authorID. +4. Socket.io CLIENT_READY with no cookie and a legacy `token` field — + still works, warn is emitted, and a subsequent HTTP request to + `/p/` gets a `Set-Cookie` with the same token value (so the + browser upgrades on its own). + +### Frontend (Playwright) + +`src/tests/frontend-new/specs/author_token_cookie.spec.ts`: + +- Fresh context opens a pad; assert `document.cookie` does **not** + contain `token` (the cookie exists but is HttpOnly) via + `context.cookies()`, which returns HttpOnly cookies from Playwright's + browser-level API. Assert the `httpOnly` / `secure` / `sameSite` + fields are what we expect. +- Reload the pad in the same context — the user's `authorID` (from + `clientVars.userId`) stays the same across reloads, proving the + cookie is the real identity source. +- Open a second, isolated browser context — `authorID` differs, as + expected for a new anonymous identity. + +### Regression + +- Existing pad-load + collaboration specs stay green without changes; + they don't touch the token path directly. + +## Rollout / back-compat + +- **Default on.** No settings toggle — the new cookie is HttpOnly from + day one. Operators who relied on reading `token` from JS + have to switch to server-side bearers (there's no legitimate reason + for page JS to read an author token). +- Legacy `message.token` field is honoured for one release and then + removable. A warn fires once per author session when the legacy + path is taken. +- `token2author:` storage unchanged. Hashed storage is PR5. +- `doc/cookies.md` updated: the `token` row now lists + `HttpOnly: true`. From 4456eb66fc5fc8571f39e30f75d34cdbd369236d Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:25:10 +0100 Subject: [PATCH 02/10] docs: PR3 GDPR anon identity implementation plan --- .../2026-04-19-gdpr-pr3-anon-identity.md | 587 ++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md diff --git a/docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md b/docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md new file mode 100644 index 00000000000..e0637bbc9bf --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md @@ -0,0 +1,587 @@ +# GDPR PR3 — Anonymous Identity Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the anonymous author-token cookie from a client-set, JS-readable cookie to a server-set `HttpOnly; Secure; SameSite=Lax` cookie. Keep legacy `token` in the socket message working for one release. + +**Architecture:** A tiny server-side helper `ensureAuthorTokenCookie(req, res)` is called from the `/p/:pad` and `/p/:pad/timeslider` handlers. It mints a `t.` token on first visit, writes it via `res.cookie()` with HttpOnly, and otherwise passes through. `handleClientReady` now reads the token from `socket.request.cookies` first, falling back to `message.token` with a one-time deprecation warn. The browser side drops the client-side token generation and the `token` field in CLIENT_READY. + +**Tech Stack:** TypeScript, Express, cookie-parser (already mounted), Playwright for frontend tests, Mocha + supertest for backend tests. + +--- + +## File Structure + +**Created by this plan:** +- `src/node/utils/ensureAuthorTokenCookie.ts` — the server-side helper +- `src/tests/backend/specs/authorTokenCookie.ts` — backend integration tests +- `src/tests/frontend-new/specs/author_token_cookie.spec.ts` — Playwright tests + +**Modified by this plan:** +- `src/node/hooks/express/specialpages.ts` — call the helper inside the `/p/:pad` and `/p/:pad/timeslider` handlers +- `src/node/handler/PadMessageHandler.ts` — read token from `socket.request.cookies` first, warn on legacy fallback +- `src/static/js/pad.ts` — drop the client-side token read/write; stop sending `token` in CLIENT_READY +- `doc/cookies.md` — flip the `token` row to `HttpOnly: true`, note the migration +- `doc/privacy.md` — add one sentence saying Etherpad never falls back to IP for identity + +--- + +## Task 1: `ensureAuthorTokenCookie` helper + unit tests + +**Files:** +- Create: `src/node/utils/ensureAuthorTokenCookie.ts` +- Create: `src/tests/backend/specs/ensureAuthorTokenCookie.ts` + +- [ ] **Step 1: Write the failing unit test** + +```typescript +// src/tests/backend/specs/ensureAuthorTokenCookie.ts +'use strict'; + +import {strict as assert} from 'assert'; +import {ensureAuthorTokenCookie} from '../../../node/utils/ensureAuthorTokenCookie'; + +type CookieCall = {name: string, value: string, opts: any}; +const fakeRes = () => { + const calls: CookieCall[] = []; + return { + calls, + secure: false, + cookie(name: string, value: string, opts: any) { calls.push({name, value, opts}); }, + }; +}; + +const cp = 'ep_'; // cookiePrefix +const settingsStub = {cookie: {prefix: cp}} as any; + +describe(__filename, function () { + it('mints a fresh t.* token when the cookie is absent', function () { + const req: any = {secure: false, cookies: {}, headers: {}}; + const res: any = {secure: false, ...fakeRes()}; + const token = ensureAuthorTokenCookie(req, res, settingsStub); + assert.ok(typeof token === 'string' && token.startsWith('t.')); + assert.equal(res.calls.length, 1); + assert.equal(res.calls[0].name, `${cp}token`); + assert.equal(res.calls[0].value, token); + assert.equal(res.calls[0].opts.httpOnly, true); + assert.equal(res.calls[0].opts.sameSite, 'lax'); + assert.equal(res.calls[0].opts.path, '/'); + }); + + it('reuses the cookie value and does not emit Set-Cookie when already set', + function () { + const req: any = { + secure: false, + cookies: {[`${cp}token`]: 't.abcdefghij1234567890'}, + headers: {}, + }; + const res: any = fakeRes(); + const token = ensureAuthorTokenCookie(req, res, settingsStub); + assert.equal(token, 't.abcdefghij1234567890'); + assert.equal(res.calls.length, 0); + }); + + it('sets Secure when the request is HTTPS', function () { + const req: any = {secure: true, cookies: {}, headers: {}}; + const res: any = fakeRes(); + ensureAuthorTokenCookie(req, res, settingsStub); + assert.equal(res.calls[0].opts.secure, true); + }); + + it('uses SameSite=None when embedded cross-site (Sec-Fetch-Site: cross-site)', + function () { + const req: any = { + secure: true, + cookies: {}, + headers: {'sec-fetch-site': 'cross-site'}, + }; + const res: any = fakeRes(); + ensureAuthorTokenCookie(req, res, settingsStub); + assert.equal(res.calls[0].opts.sameSite, 'none'); + }); + + it('ignores an invalid existing cookie and mints a fresh one', function () { + const req: any = {secure: false, cookies: {[`${cp}token`]: 'not-a-token'}, headers: {}}; + const res: any = fakeRes(); + const token = ensureAuthorTokenCookie(req, res, settingsStub); + assert.ok(token.startsWith('t.')); + assert.equal(res.calls.length, 1); + assert.notEqual(res.calls[0].value, 'not-a-token'); + }); +}); +``` + +- [ ] **Step 2: Verify the test fails (module not found)** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/ensureAuthorTokenCookie.ts --timeout 10000` +Expected: module-not-found for `../../../node/utils/ensureAuthorTokenCookie`. + +- [ ] **Step 3: Create the helper** + +```typescript +// src/node/utils/ensureAuthorTokenCookie.ts +'use strict'; + +import padutils from '../../static/js/pad_utils'; + +const isCrossSiteEmbed = (req: any): boolean => { + const fetchSite = req.headers?.['sec-fetch-site']; + return fetchSite === 'cross-site'; +}; + +/** + * Idempotent: if the request already carries a valid author-token cookie, + * returns its value and does not touch the response. Otherwise mints a fresh + * `t.` token, writes it to the response as an `HttpOnly` cookie, + * and returns it. Callers must pass the settings object rather than import it + * here so the helper stays pure and easy to unit test. + */ +export const ensureAuthorTokenCookie = ( + req: any, res: any, settings: {cookie: {prefix?: string}}, +): string => { + const prefix = settings.cookie?.prefix || ''; + const cookieName = `${prefix}token`; + const existing = req.cookies?.[cookieName]; + if (typeof existing === 'string' && padutils.isValidAuthorToken(existing)) { + return existing; + } + const token = padutils.generateAuthorToken(); + res.cookie(cookieName, token, { + httpOnly: true, + secure: Boolean(req.secure), + sameSite: isCrossSiteEmbed(req) ? 'none' : 'lax', + maxAge: 60 * 24 * 60 * 60 * 1000, // 60 days — matches the pre-PR3 client default + path: '/', + }); + return token; +}; +``` + +- [ ] **Step 4: Run the tests** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/ensureAuthorTokenCookie.ts --timeout 10000` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/utils/ensureAuthorTokenCookie.ts \ + src/tests/backend/specs/ensureAuthorTokenCookie.ts +git commit -m "feat(gdpr): ensureAuthorTokenCookie helper — HttpOnly server-set author token" +``` + +--- + +## Task 2: Wire the helper into the pad and timeslider routes + +**Files:** +- Modify: `src/node/hooks/express/specialpages.ts` — call the helper inside both `/p/:pad` handlers + +- [ ] **Step 1: Import the helper at the top of `specialpages.ts`** + +Find the other `import` lines near the top of the file and add: + +```typescript +import {ensureAuthorTokenCookie} from '../../utils/ensureAuthorTokenCookie'; +``` + +- [ ] **Step 2: Call the helper inside the `/p/:pad` `setRouteHandler`** + +Locate the `setRouteHandler("/p/:pad", (req, res, next) => { ... })` block (around line 189). Add one line at the top of the handler, before the `isReadOnly` computation: + +```typescript + setRouteHandler("/p/:pad", (req: any, res: any, next: Function) => { + ensureAuthorTokenCookie(req, res, settings); + // The below might break for pads being rewritten + const isReadOnly = !webaccess.userCanModify(req.params.pad, req); + // ... existing body unchanged ... + }) +``` + +- [ ] **Step 3: Call the helper in the `/p/:pad/timeslider` handler** + +Same treatment (around line 219): + +```typescript + setRouteHandler("/p/:pad/timeslider", (req: any, res: any, next: Function) => { + ensureAuthorTokenCookie(req, res, settings); + // ... existing body unchanged ... + }) +``` + +- [ ] **Step 4: Apply the same two edits to the fallback `args.app.get('/p/:pad', ...)` and `args.app.get('/p/:pad/timeslider', ...)` routes (around lines 350 and 370)** + +Read each handler first and insert `ensureAuthorTokenCookie(req, res, settings);` as the first statement in the route callback. These routes are only hit when the live-reload server is not in play; we still want a consistent cookie in production / non-dev mode. + +- [ ] **Step 5: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/node/hooks/express/specialpages.ts +git commit -m "feat(gdpr): set HttpOnly author-token cookie from the pad routes" +``` + +--- + +## Task 3: Prefer the cookie over `message.token` in `handleClientReady` + +**Files:** +- Modify: `src/node/handler/PadMessageHandler.ts` — swap the token resolution order inside `handleClientReady` + +- [ ] **Step 1: Find the existing `token` lookup in `handleClientReady`** + +Run: `grep -n "message.token\|messageToken" src/node/handler/PadMessageHandler.ts | head` + +This locates the line where `token` is read from the message (there is typically a destructure like `const {token, sessionID, …} = message`). Read the surrounding 20 lines to understand the surrounding context. + +- [ ] **Step 2: Replace the lookup** + +Replace the line(s) that resolve `token` with this block: + +```typescript + const cookiePrefix = settings.cookie?.prefix || ''; + const cookieToken = socket.request?.cookies?.[`${cookiePrefix}token`]; + const legacyToken = typeof message.token === 'string' ? message.token : null; + const token = cookieToken || legacyToken; + if (!cookieToken && legacyToken) { + if (!sessionInfo.legacyTokenWarned) { + messageLogger.warn( + 'client sent author token via CLIENT_READY message; cookie migration ' + + 'will take effect on next HTTP response. ' + + 'See docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md'); + sessionInfo.legacyTokenWarned = true; + } + } +``` + +The rest of `handleClientReady` continues to use the resolved `token` unchanged. + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/node/handler/PadMessageHandler.ts +git commit -m "feat(gdpr): read author token from cookie first, keep message.token fallback" +``` + +--- + +## Task 4: Drop the client-side token read/write + +**Files:** +- Modify: `src/static/js/pad.ts` — remove the token generation + cookie-set block, stop sending `token` + +- [ ] **Step 1: Read the relevant block** + +Lines 190-195 of `src/static/js/pad.ts` currently do: + +```typescript + 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(`${cp}token`, token, {expires: 60}); + } +``` + +- [ ] **Step 2: Remove those lines and drop the `token` field from the CLIENT_READY message** + +Replace the block with a single comment, and remove `token` from the message literal that follows (line ~212): + +```typescript + // Author token lives in an HttpOnly cookie set by the server (#6701 PR3). + // The browser never reads or writes it; the server reads the cookie off + // the socket.io handshake request in handleClientReady. +``` + +Also, just below, in the `msg` literal, remove the `token,` line so the shorthand property goes away. + +- [ ] **Step 3: Remove the now-unused `token` local from the reconnect path** + +If the reconnect branch below the `msg` literal reads the local `token`, either inline the `undefined` or clean up the reference. Read lines 215-225 first — they may or may not need changes. + +- [ ] **Step 4: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/static/js/pad.ts +git commit -m "feat(gdpr): stop generating the author token client-side" +``` + +--- + +## Task 5: Backend integration tests — cookie lifecycle + +**Files:** +- Create: `src/tests/backend/specs/authorTokenCookie.ts` + +- [ ] **Step 1: Write the integration test** + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../common'); +const setCookieParser = require('set-cookie-parser'); + +describe(__filename, function () { + let agent: any; + + before(async function () { + this.timeout(60000); + agent = await common.init(); + }); + + const padPath = () => `/p/PR3_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + it('sets an HttpOnly token cookie on first visit', async function () { + const res = await agent.get(padPath()).expect(200); + const cookies = setCookieParser.parse(res, {map: true}); + const tokenCookie = Object.entries(cookies).find(([k]) => k.endsWith('token'))?.[1] as any; + assert.ok(tokenCookie, `expected a token cookie in ${Object.keys(cookies).join(',')}`); + assert.match(tokenCookie.value, /^t\./); + assert.equal(tokenCookie.httpOnly, true); + assert.equal(String(tokenCookie.sameSite || '').toLowerCase(), 'lax'); + assert.equal(tokenCookie.path, '/'); + }); + + it('reuses the cookie value on subsequent visits', async function () { + const path = padPath(); + const first = await agent.get(path).expect(200); + const firstCookies = setCookieParser.parse(first, {map: true}); + const firstToken = Object.entries(firstCookies).find(([k]) => k.endsWith('token'))?.[1] as any; + assert.ok(firstToken); + + const second = await agent.get(path) + .set('Cookie', `${Object.keys(firstCookies)[0]}=${firstToken.value}`) + .expect(200); + const secondCookies = setCookieParser.parse(second, {map: true}); + const resentName = Object.keys(secondCookies).find((k) => k.endsWith('token')); + assert.equal(resentName, undefined, + `server should not re-send the token cookie when one is already present`); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/authorTokenCookie.ts --timeout 30000` +Expected: 2 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/backend/specs/authorTokenCookie.ts +git commit -m "test(gdpr): server sets + reuses the HttpOnly author-token cookie" +``` + +--- + +## Task 6: Playwright — identity persists across reload, not across contexts + +**Files:** +- Create: `src/tests/frontend-new/specs/author_token_cookie.spec.ts` + +- [ ] **Step 1: Write the Playwright spec** + +```typescript +import {expect, test} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; +import {goToNewPad} from '../helper/padHelper'; + +test.describe('author token cookie', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('author token cookie is HttpOnly and not readable via document.cookie', + async ({page, context}) => { + await goToNewPad(page); + + const cookies = await context.cookies(); + const tokenCookie = cookies.find((c) => c.name.endsWith('token')); + expect(tokenCookie, `cookies: ${JSON.stringify(cookies.map((c) => c.name))}`) + .toBeDefined(); + expect(tokenCookie!.httpOnly).toBe(true); + expect(tokenCookie!.sameSite.toLowerCase()).toBe('lax'); + + const jsVisible = await page.evaluate(() => document.cookie); + expect(jsVisible).not.toContain(tokenCookie!.name); + }); + + test('authorID is stable across reload in the same context', async ({page}) => { + await goToNewPad(page); + const first = await page.evaluate(() => (window as any).clientVars?.userId); + await page.reload(); + await page.waitForSelector('#editorcontainer.initialized'); + const second = await page.evaluate(() => (window as any).clientVars?.userId); + expect(second).toBe(first); + }); + + test('authorID differs in an isolated second context', async ({page, browser}) => { + const padId = await goToNewPad(page); + const first = await page.evaluate(() => (window as any).clientVars?.userId); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await page2.goto(`http://localhost:9001/p/${padId}`); + await page2.waitForSelector('#editorcontainer.initialized'); + const second = await page2.evaluate(() => (window as any).clientVars?.userId); + expect(second).not.toBe(first); + await context2.close(); + }); +}); +``` + +- [ ] **Step 2: Restart the test server so it picks up the Task 1–4 code** + +```bash +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2 +(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \ + --settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &) +sleep 10 +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | tail -2 +``` + +Expected: port 9001 listening. + +- [ ] **Step 3: Run the Playwright spec** + +```bash +cd src && NODE_ENV=production npx playwright test author_token_cookie --project=chromium +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/frontend-new/specs/author_token_cookie.spec.ts +git commit -m "test(gdpr): Playwright coverage for the HttpOnly author-token cookie" +``` + +--- + +## Task 7: Docs + +**Files:** +- Modify: `doc/cookies.md` — update the `token` row to `HttpOnly: true`, note the server-side set +- Modify: `doc/privacy.md` — add one sentence clarifying Etherpad does not fall back to IP for identity + +- [ ] **Step 1: Read `doc/cookies.md` and find the token row** + +Run: `grep -n "token" doc/cookies.md` + +Locate the row describing the author token (likely the one that mentions `60 days` or `pad_utils`). Replace the `Http-only` column value (currently `false`) with `true`, and update the description to read: *Set by the server as an HttpOnly cookie on the first pad GET (`/p/:pad`). The server reads it from the socket.io handshake to resolve the author. See [privacy.md](privacy.md).* + +- [ ] **Step 2: Add the identity-fallback sentence to `doc/privacy.md`** + +Append to the existing "What Etherpad does not do" bullet list in `doc/privacy.md` (shipped in PR2): + +```markdown +- IP addresses are never used as an identity fallback. The anonymous + author identity is carried by an HttpOnly `token` cookie + issued by the server on first pad visit; see + [cookies.md](cookies.md). +``` + +- [ ] **Step 3: Commit** + +```bash +git add doc/cookies.md doc/privacy.md +git commit -m "docs(gdpr): flip token cookie to HttpOnly + no-IP-identity note" +``` + +--- + +## Task 8: End-to-end verification, push, open PR + +**Files:** (no edits) + +- [ ] **Step 1: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 2: Backend + frontend sweep** + +```bash +pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \ + tests/backend/specs/ensureAuthorTokenCookie.ts \ + tests/backend/specs/authorTokenCookie.ts --timeout 30000 + +cd src && NODE_ENV=production npx playwright test \ + author_token_cookie chat.spec enter.spec --project=chromium +``` + +Expected: all tests pass. + +- [ ] **Step 3: Push and open the PR** + +```bash +git push origin feat-gdpr-anon-identity +gh pr create --repo ether/etherpad --base develop --head feat-gdpr-anon-identity \ + --title "feat(gdpr): HttpOnly author-token cookie (PR3 of #6701)" --body "$(cat <<'EOF' +## Summary +- Author-token cookie is now minted and set by the server on the pad route as `HttpOnly; Secure (on HTTPS); SameSite=Lax` (or `None` when cross-site embedded). +- Browser JavaScript no longer reads, writes, or sends the token. +- `handleClientReady` reads the token from the socket.io handshake cookies; legacy `message.token` field is honoured for one release with a one-time WARN. +- No IP-based identity fallback (documented in `privacy.md`). + +Part of the GDPR work tracked in #6701. PR1 (#7546) landed deletion controls; PR2 (#7547) landed the IP-logging audit. Remaining PR4 (cookie banner) and PR5 (author erasure) stay in follow-ups. + +Design spec: `docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md` +Implementation plan: `docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md` + +## Test plan +- [x] ts-check clean +- [x] ensureAuthorTokenCookie unit tests (5 cases) +- [x] authorTokenCookie integration tests (set-once + reuse) +- [x] Playwright (HttpOnly attribute, cross-reload stability, context isolation) +EOF +)" +``` + +- [ ] **Step 4: Monitor CI** + +Run: `gh pr checks --repo ether/etherpad` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task(s) | +| --- | --- | +| Server mints + sets HttpOnly cookie | 1, 2 | +| Cookie attributes (HttpOnly/Secure/SameSite/maxAge/path) | 1 | +| Socket handshake reads cookie; falls back to `message.token` with WARN | 3 | +| Client stops generating the token | 4 | +| IP-fallback documentation | 7 | +| Backend integration tests | 5 | +| Frontend tests (HttpOnly, stability, isolation) | 6 | +| `doc/cookies.md` flip + `doc/privacy.md` sentence | 7 | + +All spec sections have a task. + +**Placeholders:** none — every code block is complete. + +**Type consistency:** +- `ensureAuthorTokenCookie(req, res, settings)` signature identical in Tasks 1, 2, 5. +- `t.` token format consistent across Tasks 1 (mint), 3 (resolution), 5 (regex assertion `/^t\./`). +- `sessionInfo.legacyTokenWarned` flag used only inside Task 3. +- `message.token` field touched in Tasks 3 (server read) and 4 (client drop); types stay in sync because no type file declares the client-outgoing `token` field separately. From 7da82c637ddd0c5c071a2424eef7a673d49f7405 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:26:40 +0100 Subject: [PATCH 03/10] =?UTF-8?q?feat(gdpr):=20ensureAuthorTokenCookie=20h?= =?UTF-8?q?elper=20=E2=80=94=20HttpOnly=20server-set=20author=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/utils/ensureAuthorTokenCookie.ts | 35 +++++++++ .../backend/specs/ensureAuthorTokenCookie.ts | 77 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/node/utils/ensureAuthorTokenCookie.ts create mode 100644 src/tests/backend/specs/ensureAuthorTokenCookie.ts diff --git a/src/node/utils/ensureAuthorTokenCookie.ts b/src/node/utils/ensureAuthorTokenCookie.ts new file mode 100644 index 00000000000..55b5d0b8607 --- /dev/null +++ b/src/node/utils/ensureAuthorTokenCookie.ts @@ -0,0 +1,35 @@ +'use strict'; + +import padutils from '../../static/js/pad_utils'; + +const isCrossSiteEmbed = (req: any): boolean => { + const fetchSite = req.headers?.['sec-fetch-site']; + return fetchSite === 'cross-site'; +}; + +/** + * Idempotent: if the request already carries a valid author-token cookie, + * returns its value and does not touch the response. Otherwise mints a fresh + * `t.` token, writes it to the response as an HttpOnly cookie, + * and returns it. Callers pass the settings object rather than importing it + * here so the helper stays pure and easy to unit test. + */ +export const ensureAuthorTokenCookie = ( + req: any, res: any, settings: {cookie: {prefix?: string}}, +): string => { + const prefix = settings.cookie?.prefix || ''; + const cookieName = `${prefix}token`; + const existing = req.cookies?.[cookieName]; + if (typeof existing === 'string' && padutils.isValidAuthorToken(existing)) { + return existing; + } + const token = padutils.generateAuthorToken(); + res.cookie(cookieName, token, { + httpOnly: true, + secure: Boolean(req.secure), + sameSite: isCrossSiteEmbed(req) ? 'none' : 'lax', + maxAge: 60 * 24 * 60 * 60 * 1000, // 60 days — matches the pre-PR3 client default + path: '/', + }); + return token; +}; diff --git a/src/tests/backend/specs/ensureAuthorTokenCookie.ts b/src/tests/backend/specs/ensureAuthorTokenCookie.ts new file mode 100644 index 00000000000..3fe07154363 --- /dev/null +++ b/src/tests/backend/specs/ensureAuthorTokenCookie.ts @@ -0,0 +1,77 @@ +'use strict'; + +import {strict as assert} from 'assert'; +import {ensureAuthorTokenCookie} from '../../../node/utils/ensureAuthorTokenCookie'; + +type CookieCall = {name: string, value: string, opts: any}; +const fakeRes = () => { + const calls: CookieCall[] = []; + return { + calls, + cookie(name: string, value: string, opts: any) { calls.push({name, value, opts}); }, + }; +}; + +const cp = 'ep_'; +const settingsStub = {cookie: {prefix: cp}} as any; + +describe(__filename, function () { + it('mints a fresh t.* token when the cookie is absent', function () { + const req: any = {secure: false, cookies: {}, headers: {}}; + const res: any = fakeRes(); + const token = ensureAuthorTokenCookie(req, res, settingsStub); + assert.ok(typeof token === 'string' && token.startsWith('t.'), + `token=${token}`); + assert.equal(res.calls.length, 1); + assert.equal(res.calls[0].name, `${cp}token`); + assert.equal(res.calls[0].value, token); + assert.equal(res.calls[0].opts.httpOnly, true); + assert.equal(res.calls[0].opts.sameSite, 'lax'); + assert.equal(res.calls[0].opts.path, '/'); + }); + + it('reuses the cookie value and does not emit Set-Cookie when already set', + function () { + const req: any = { + secure: false, + cookies: {[`${cp}token`]: 't.abcdefghij1234567890'}, + headers: {}, + }; + const res: any = fakeRes(); + const token = ensureAuthorTokenCookie(req, res, settingsStub); + assert.equal(token, 't.abcdefghij1234567890'); + assert.equal(res.calls.length, 0); + }); + + it('sets Secure when the request is HTTPS', function () { + const req: any = {secure: true, cookies: {}, headers: {}}; + const res: any = fakeRes(); + ensureAuthorTokenCookie(req, res, settingsStub); + assert.equal(res.calls[0].opts.secure, true); + }); + + it('uses SameSite=None when embedded cross-site (Sec-Fetch-Site: cross-site)', + function () { + const req: any = { + secure: true, + cookies: {}, + headers: {'sec-fetch-site': 'cross-site'}, + }; + const res: any = fakeRes(); + ensureAuthorTokenCookie(req, res, settingsStub); + assert.equal(res.calls[0].opts.sameSite, 'none'); + }); + + it('ignores an invalid existing cookie and mints a fresh one', function () { + const req: any = { + secure: false, + cookies: {[`${cp}token`]: 'not-a-token'}, + headers: {}, + }; + const res: any = fakeRes(); + const token = ensureAuthorTokenCookie(req, res, settingsStub); + assert.ok(token.startsWith('t.')); + assert.equal(res.calls.length, 1); + assert.notEqual(res.calls[0].value, 'not-a-token'); + }); +}); From 232df62bd98d6016c95d791c28582b8578cdff53 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:27:52 +0100 Subject: [PATCH 04/10] feat(gdpr): set HttpOnly author-token cookie from the pad routes --- src/node/hooks/express/specialpages.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 2863074e2fd..e4c622a0cc0 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -7,6 +7,7 @@ const fsp = fs.promises; const toolbar = require('../../utils/toolbar'); const hooks = require('../../../static/js/pluginfw/hooks'); import settings, {getEpVersion} from '../../utils/Settings'; +import {ensureAuthorTokenCookie} from '../../utils/ensureAuthorTokenCookie'; import util from 'node:util'; const webaccess = require('./webaccess'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); @@ -187,6 +188,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl setRouteHandler("/p/:pad", (req: any, res: any, next: Function) => { + ensureAuthorTokenCookie(req, res, settings); // The below might break for pads being rewritten const isReadOnly = !webaccess.userCanModify(req.params.pad, req); @@ -217,6 +219,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl }) setRouteHandler("/p/:pad/timeslider", (req: any, res: any, next: Function) => { + ensureAuthorTokenCookie(req, res, settings); console.log("Reloading pad") // The below might break for pads being rewritten const isReadOnly = !webaccess.userCanModify(req.params.pad, req); @@ -348,6 +351,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c // serve pad.html under /p args.app.get('/p/:pad', (req: any, res: any, next: Function) => { + ensureAuthorTokenCookie(req, res, settings); // The below might break for pads being rewritten const isReadOnly = !webaccess.userCanModify(req.params.pad, req); @@ -368,6 +372,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c // serve timeslider.html under /p/$padname/timeslider args.app.get('/p/:pad/timeslider', (req: any, res: any, next: Function) => { + ensureAuthorTokenCookie(req, res, settings); hooks.callAll('padInitToolbar', { toolbar, }); From b50b7d31b3a1a16af1cedacbed1b3427f43f1500 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:29:04 +0100 Subject: [PATCH 05/10] feat(gdpr): read author token from cookie first, keep message.token fallback --- src/node/handler/PadMessageHandler.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 072ae648ba5..e01f7b44ac4 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -291,13 +291,28 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => { if (!thisSession) throw new Error('message from an unknown connection'); if (message.type === 'CLIENT_READY') { + // Prefer the HttpOnly author-token cookie over the in-message token (GDPR + // PR3). Legacy clients (pre-PR3 browsers or API consumers) still send + // `token` in the CLIENT_READY payload — honour it one more release, warn + // once so the migration is visible in logs. + const cookiePrefix = settings.cookie?.prefix || ''; + const cookieToken = socket.request?.cookies?.[`${cookiePrefix}token`]; + const legacyToken = typeof message.token === 'string' ? message.token : null; + const resolvedToken = cookieToken || legacyToken; + if (!cookieToken && legacyToken && !thisSession.legacyTokenWarned) { + messageLogger.warn( + 'client sent author token via CLIENT_READY message; cookie migration ' + + 'will take effect on next HTTP response. ' + + 'See docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md'); + thisSession.legacyTokenWarned = 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, padID: message.padId, - token: message.token, + token: resolvedToken, }; // Pad does not exist, so we need to sanitize the id From 641a9afd0e7ca68915ee975bb8a2ec52508c09c4 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:29:44 +0100 Subject: [PATCH 06/10] feat(gdpr): stop generating the author token client-side --- src/static/js/pad.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index d9cc4e902ed..32d3cc99612 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -188,11 +188,9 @@ const sendClientReady = (isReconnect) => { } 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(`${cp}token`, token, {expires: 60}); - } + // 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 + // reads the cookie from the socket.io handshake inside handleClientReady. // 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 @@ -209,7 +207,6 @@ const sendClientReady = (isReconnect) => { type: 'CLIENT_READY', padId, sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'), - token, userInfo, }; From b0b83cc7dfc01977dae0558c66828bcef3b6ad03 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:33:09 +0100 Subject: [PATCH 07/10] test(gdpr): server sets + reuses the HttpOnly author-token cookie --- src/tests/backend/specs/authorTokenCookie.ts | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/tests/backend/specs/authorTokenCookie.ts diff --git a/src/tests/backend/specs/authorTokenCookie.ts b/src/tests/backend/specs/authorTokenCookie.ts new file mode 100644 index 00000000000..550f35c1917 --- /dev/null +++ b/src/tests/backend/specs/authorTokenCookie.ts @@ -0,0 +1,47 @@ +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../common'); +const setCookieParser = require('set-cookie-parser'); + +describe(__filename, function () { + let agent: any; + + before(async function () { + this.timeout(60000); + agent = await common.init(); + }); + + const padPath = () => `/p/PR3_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + it('sets an HttpOnly token cookie on first visit', async function () { + const res = await agent.get(padPath()).expect(200); + const cookies = setCookieParser.parse(res, {map: true}); + const tokenEntry = Object.entries(cookies).find(([k]) => k.endsWith('token')); + assert.ok(tokenEntry, + `expected a token cookie; got: ${Object.keys(cookies).join(',')}`); + const [, tokenCookie] = tokenEntry as [string, any]; + assert.match(tokenCookie.value, /^t\./); + assert.equal(tokenCookie.httpOnly, true); + assert.equal(String(tokenCookie.sameSite || '').toLowerCase(), 'lax'); + assert.equal(tokenCookie.path, '/'); + }); + + it('reuses the cookie value on subsequent visits', async function () { + const path = padPath(); + const first = await agent.get(path).expect(200); + const firstCookies = setCookieParser.parse(first, {map: true}); + const firstEntry = Object.entries(firstCookies).find(([k]) => k.endsWith('token')); + assert.ok(firstEntry); + const [name, tokenCookie] = firstEntry as [string, any]; + + const second = await agent.get(path) + .set('Cookie', `${name}=${tokenCookie.value}`) + .expect(200); + const secondCookies = setCookieParser.parse(second, {map: true}); + const resent = Object.keys(secondCookies).find((k) => k.endsWith('token')); + assert.equal(resent, undefined, + `server should not re-send the token cookie when one is already present`); + }); +}); From c685bb431afb33df443c86e71deec2a28208a4fc Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:37:34 +0100 Subject: [PATCH 08/10] fix+test(gdpr): parse token cookie from handshake Cookie header socket.io handshake doesn't run cookie-parser, so socket.request.cookies is undefined. Parse the Cookie header directly in handleClientReady so the HttpOnly token actually resolves. Playwright spec covers HttpOnly attribute, reload-stability, and context-isolation. --- src/node/handler/PadMessageHandler.ts | 10 +++- .../specs/author_token_cookie.spec.ts | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/tests/frontend-new/specs/author_token_cookie.spec.ts diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index e01f7b44ac4..dee659257a5 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -294,9 +294,15 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => { // Prefer the HttpOnly author-token cookie over the in-message token (GDPR // PR3). Legacy clients (pre-PR3 browsers or API consumers) still send // `token` in the CLIENT_READY payload — honour it one more release, warn - // once so the migration is visible in logs. + // 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. const cookiePrefix = settings.cookie?.prefix || ''; - const cookieToken = socket.request?.cookies?.[`${cookiePrefix}token`]; + 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 legacyToken = typeof message.token === 'string' ? message.token : null; const resolvedToken = cookieToken || legacyToken; if (!cookieToken && legacyToken && !thisSession.legacyTokenWarned) { diff --git a/src/tests/frontend-new/specs/author_token_cookie.spec.ts b/src/tests/frontend-new/specs/author_token_cookie.spec.ts new file mode 100644 index 00000000000..d529c6ec563 --- /dev/null +++ b/src/tests/frontend-new/specs/author_token_cookie.spec.ts @@ -0,0 +1,49 @@ +import {expect, test} from '@playwright/test'; +import {goToNewPad} from '../helper/padHelper'; + +test.describe('author token cookie', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('author token cookie is HttpOnly and not readable via document.cookie', + async ({page, context}) => { + await goToNewPad(page); + + const cookies = await context.cookies(); + const tokenCookie = cookies.find((c) => c.name.endsWith('token')); + expect(tokenCookie, + `cookies: ${JSON.stringify(cookies.map((c) => c.name))}`).toBeDefined(); + expect(tokenCookie!.httpOnly).toBe(true); + expect(String(tokenCookie!.sameSite).toLowerCase()).toBe('lax'); + + const jsVisible = await page.evaluate(() => document.cookie); + expect(jsVisible).not.toContain(tokenCookie!.name); + }); + + test('authorID is stable across reload in the same context', async ({page}) => { + await goToNewPad(page); + const first = await page.evaluate(() => (window as any).clientVars?.userId); + await page.reload(); + await page.waitForSelector('#editorcontainer.initialized'); + const second = await page.evaluate(() => (window as any).clientVars?.userId); + expect(second).toBe(first); + }); + + test('authorID differs in an isolated second context', async ({page, browser, context}) => { + const padId = await goToNewPad(page); + const first = await page.evaluate(() => (window as any).clientVars?.userId); + const firstCookie = (await context.cookies()).find((c) => c.name.endsWith('token')); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await page2.goto(`http://localhost:9001/p/${padId}`); + await page2.waitForSelector('#editorcontainer.initialized'); + const second = await page2.evaluate(() => (window as any).clientVars?.userId); + const secondCookie = (await context2.cookies()).find((c) => c.name.endsWith('token')); + + expect(secondCookie?.value).not.toBe(firstCookie?.value); + expect(second).not.toBe(first); + await context2.close(); + }); +}); From f842ead7a48fd85a0a162a61cfc87f9dfeceb432 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:38:34 +0100 Subject: [PATCH 09/10] docs(gdpr): token cookie is now HttpOnly + server-set --- doc/cookies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/cookies.md b/doc/cookies.md index 171de8a4640..37affae19aa 100644 --- a/doc/cookies.md +++ b/doc/cookies.md @@ -7,7 +7,7 @@ Cookies used by Etherpad. | express_sid | s%3A7yCNjRmTW8ylGQ53I2IhOwYF9... | example.org | / | Session | true | true | Session ID of the [Express web framework](https://expressjs.com). When Etherpad is behind a reverse proxy, and an administrator wants to use session stickiness, he may use this cookie. If you are behind a reverse proxy, please remember to set `trustProxy: true` in `settings.json`. Set in [webaccess.js#L131](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/hooks/express/webaccess.js#L131). | | language | en | example.org | / | Session | false | true | The language of the UI (e.g.: `en-GB`, `it`). Set in [pad_editor.js#L111](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_editor.js#L111). | | prefs / prefsHttp | %7B%22epThemesExtTheme%22... | example.org | /p | year 3000 | false | true | Client-side preferences (e.g.: font family, chat always visible, show authorship colors, ...). Set in [pad_cookie.js#L49](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_cookie.js#L49). `prefs` is used if Etherpad is accessed over HTTPS, `prefsHttp` if accessed over HTTP. For more info see https://github.com/ether/etherpad-lite/issues/3179. | -| token | t.tFzkihhhBf4xKEpCK3PU | example.org | / | 60 days | false | true | A random token representing the author, of the form `t.randomstring_of_lenght_20`. The random string is generated by the client, at ([pad.js#L55-L66](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L55-L66)). This cookie is always set by the client (at [pad.js#L153-L158](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L153-L158)) without any solicitation from the server. It is used for all the pads accessed via the web UI (not used for the HTTP API). On the server side, its value is accessed at [SecurityManager.js#L33](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/db/SecurityManager.js#L33). | +| token | t.tFzkihhhBf4xKEpCK3PU | example.org | / | 60 days | true | true | A random token representing the author, of the form `t.randomstring_of_length_20`. Set by the server as an `HttpOnly; SameSite=Lax` cookie on the first GET to `/p/:pad` (see `src/node/utils/ensureAuthorTokenCookie.ts`). The server reads the cookie from the socket.io handshake in `PadMessageHandler.handleClientReady` to resolve the author. Not readable from browser JavaScript. See [privacy.md](privacy.md). | For more info, visit the related discussion at https://github.com/ether/etherpad-lite/issues/3563. From 89fde85882a7ddcb64873adcaaa241fd40651b6f Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 11:23:46 +0100 Subject: [PATCH 10/10] fix(gdpr): close two HttpOnly token bypasses Qodo review: - Timeslider still ran the pre-PR3 JS-cookie path: it read Cookies.get('${cp}token') (which HttpOnly hides), then generated a fresh plaintext token and overwrote the server's HttpOnly cookie with it, and sent token in every socket message. Strip the token read/ write entirely from timeslider.ts and from the outgoing message shape; the server reads the cookie off the socket.io handshake just like on /p/:pad. - tokenTransfer re-issued the author cookie without HttpOnly, undoing the hardening the first time a user transferred a session. Re-set it as HttpOnly + Secure (on HTTPS) + SameSite=Lax. Also stop trusting the body-supplied token on POST: read it off req.cookies server-side so the client never needs JS access to the token. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/hooks/express/tokenTransfer.ts | 47 ++++++++++++++++++------- src/static/js/timeslider.ts | 14 +++----- src/static/js/welcome.ts | 4 ++- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/node/hooks/express/tokenTransfer.ts b/src/node/hooks/express/tokenTransfer.ts index 5a0ccbe01e9..24962c8bcde 100644 --- a/src/node/hooks/express/tokenTransfer.ts +++ b/src/node/hooks/express/tokenTransfer.ts @@ -13,20 +13,31 @@ type TokenTransferRequest = { const tokenTransferKey = "tokenTransfer:"; export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => { - app.post('/tokenTransfer', async (req, res) => { - const token = req.body as TokenTransferRequest; - if (!token || !token.token) { - return res.status(400).send({error: 'Invalid request'}); + app.post('/tokenTransfer', async (req: any, res) => { + // The author token is HttpOnly (ether/etherpad#6701 PR3) so the browser + // cannot read it. Read it off the request's own cookie jar instead of + // trusting the request body. The client still supplies non-HttpOnly + // prefs via body because `prefsHttp` is intentionally JS-readable. + const cp = settings.cookie.prefix || ''; + const authorToken: string | undefined = + req.cookies?.[`${cp}token`] || req.cookies?.token; + const body = (req.body || {}) as Partial; + if (!authorToken) { + return res.status(400).send({error: 'No author cookie to transfer'}); } - const id = crypto.randomUUID() - token.createdAt = Date.now(); + const id = crypto.randomUUID(); + const token: TokenTransferRequest = { + token: authorToken, + prefsHttp: body.prefsHttp || '', + createdAt: Date.now(), + }; - await db.set(`${tokenTransferKey}:${id}`, token) + await db.set(`${tokenTransferKey}:${id}`, token); res.send({id}); }) - app.get('/tokenTransfer/:token', async (req, res) => { + app.get('/tokenTransfer/:token', async (req: any, res) => { const id = req.params.token; if (!id) { return res.status(400).send({error: 'Invalid request'}); @@ -37,11 +48,21 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => return res.status(404).send({error: 'Token not found'}); } - const token = await db.get(`${tokenTransferKey}:${id}`) - 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); + // Re-issue the author token on the new device as an HttpOnly cookie to + // match the /p/:pad path (ether/etherpad#6701 PR3). Without this, the + // transfer would reintroduce a JS-readable copy of the token. + res.cookie(`${p}token`, tokenData.token, { + path: '/', + maxAge: 1000 * 60 * 60 * 24 * 365, + httpOnly: true, + secure: Boolean(req.secure), + sameSite: 'lax', + }); + // prefsHttp is intentionally JS-readable — do NOT mark HttpOnly. + res.cookie(`${p}prefsHttp`, tokenData.prefsHttp, { + path: '/', maxAge: 1000 * 60 * 60 * 24 * 365, + }); + res.send(tokenData); }) } diff --git a/src/static/js/timeslider.ts b/src/static/js/timeslider.ts index f065aee4112..f25b9ce6175 100644 --- a/src/static/js/timeslider.ts +++ b/src/static/js/timeslider.ts @@ -27,12 +27,12 @@ // assigns to the global `$` and augments it with plugins. require('./vendors/jquery'); -import {randomString, Cookies} from "./pad_utils"; +import {Cookies} from "./pad_utils"; const hooks = require('./pluginfw/hooks'); import padutils from './pad_utils' const socketio = require('./socketio'); import html10n from '../js/vendors/html10n' -let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider; +let padId, exportLinks, socket, changesetLoader, BroadcastSlider; let cp = ''; const playbackSpeedCookie = 'timesliderPlaybackSpeed'; @@ -49,13 +49,10 @@ const init = () => { // set the title document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`; - // ensure we have a token + // The author token is an HttpOnly cookie set by the server on + // /p/:pad/timeslider (ether/etherpad#6701 PR3). The browser never reads + // or writes it; the server picks it up from the socket.io handshake. cp = (window as any).clientVars?.cookiePrefix || ''; - token = Cookies.get(`${cp}token`) || Cookies.get('token'); - if (token == null) { - token = `t.${randomString()}`; - Cookies.set(`${cp}token`, token, {expires: 60}); - } socket = socketio.connect(exports.baseURL, '/', {query: {padId}}); @@ -103,7 +100,6 @@ const sendSocketMsg = (type, data) => { type, data, padId, - token, sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'), }); }; diff --git a/src/static/js/welcome.ts b/src/static/js/welcome.ts index f4e87427d90..7d510c9d4a8 100644 --- a/src/static/js/welcome.ts +++ b/src/static/js/welcome.ts @@ -21,6 +21,9 @@ function handleTransferOfSession() { transferNowButton.innerHTML = `${checkmark}`; transferNowButton.disabled = true; + // The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot + // read it via document.cookie. Send only the JS-readable prefsHttp; the + // server reads the token off the request's own cookie jar. const responseWithId = await fetch("./tokenTransfer", { method: "POST", headers: { @@ -28,7 +31,6 @@ function handleTransferOfSession() { }, body: JSON.stringify({ prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'), - token: getCookie(`${cp}token`) || getCookie('token'), }) })