feat(gdpr): HttpOnly author-token cookie (PR3 of #6701) (#7548)

* docs: PR3 GDPR anonymous identity hardening design spec

* docs: PR3 GDPR anon identity implementation plan

* feat(gdpr): ensureAuthorTokenCookie helper — HttpOnly server-set author token

* feat(gdpr): set HttpOnly author-token cookie from the pad routes

* feat(gdpr): read author token from cookie first, keep message.token fallback

* feat(gdpr): stop generating the author token client-side

* test(gdpr): server sets + reuses the HttpOnly author-token cookie

* 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.

* docs(gdpr): token cookie is now HttpOnly + server-set

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-03 12:56:56 +08:00 committed by GitHub
parent 9014d3a7c4
commit 49bc33f019
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1049 additions and 31 deletions

View File

@ -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 by the pad client when the user changes **My View → Language** (currently in `src/static/js/pad.ts`, via `setMyViewLanguage()`). |
| 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.

View File

@ -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.<random>` 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 `<prefix>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.<randomString>` 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 14 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 `<prefix>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 `<prefix>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 <PR-number> --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.<randomString>` 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.

View File

@ -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:<token>` → 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: <only on https>`. **`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:<token>` 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.<randomString(20)>` 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/<new pad>` with no cookies — response carries a
`Set-Cookie: <prefix>token=t.<…>; HttpOnly; SameSite=Lax`,
`Secure` asserted only when the test goes over HTTPS.
2. GET `/p/<new pad>` **again** with the `<prefix>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/<pad>` 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 `<prefix>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 `<prefix>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:<token>` storage unchanged. Hashed storage is PR5.
- `doc/cookies.md` updated: the `<prefix>token` row now lists
`HttpOnly: true`.

View File

@ -366,13 +366,34 @@ 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. The socket.io handshake does
// not run cookie-parser, so pull the cookie directly from the Cookie
// header.
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 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

View File

@ -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');
@ -192,6 +193,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);
@ -226,6 +228,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);
@ -364,6 +367,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);
@ -388,6 +392,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,
});

View File

@ -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<TokenTransferRequest>;
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);
})
}

View File

@ -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.<randomString>` 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;
};

View File

@ -295,11 +295,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
@ -316,7 +314,6 @@ const sendClientReady = (isReconnect) => {
type: 'CLIENT_READY',
padId,
sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'),
token,
userInfo,
};
const overrides = getMyViewOverrides();

View File

@ -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';
@ -80,13 +80,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}});
@ -134,7 +131,6 @@ const sendSocketMsg = (type, data) => {
type,
data,
padId,
token,
sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'),
});
};

View File

@ -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'),
})
})

View File

@ -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`);
});
});

View File

@ -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');
});
});

View File

@ -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();
});
});