etherpad-lite/src/node/hooks/express/tokenTransfer.ts
John McLear 49bc33f019
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>
2026-05-03 05:56:56 +01:00

68 lines
2.3 KiB
TypeScript

import {ArgsExpressType} from "../../types/ArgsExpressType";
const db = require('../../db/DB');
import crypto from 'crypto'
import settings from '../../utils/Settings';
type TokenTransferRequest = {
token: string;
prefsHttp: string,
createdAt?: number;
}
const tokenTransferKey = "tokenTransfer:";
export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => {
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();
const token: TokenTransferRequest = {
token: authorToken,
prefsHttp: body.prefsHttp || '',
createdAt: Date.now(),
};
await db.set(`${tokenTransferKey}:${id}`, token);
res.send({id});
})
app.get('/tokenTransfer/:token', async (req: any, res) => {
const id = req.params.token;
if (!id) {
return res.status(400).send({error: 'Invalid request'});
}
const tokenData = await db.get(`${tokenTransferKey}:${id}`);
if (!tokenData) {
return res.status(404).send({error: 'Token not found'});
}
const p = settings.cookie.prefix;
// 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);
})
}