feat(gdpr): pad deletion controls (PR1 of #6701) (#7546)

* docs: PR1 GDPR deletion-controls design spec

First of five GDPR PRs tracked in #6701. PR1 covers deletion controls:
one-time deletion token, allowPadDeletionByAllUsers flag, authorisation
matrix for handlePadDelete and the REST deletePad endpoint, a single
token-display modal for browser pad creators, and test coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: PR1 GDPR deletion-controls implementation plan

13 TDD-structured tasks covering PadDeletionManager unit tests, socket
+ REST three-way auth, clientVars wiring, one-time token modal,
delete-with-token UI, Playwright coverage, and PR handoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gdpr): scaffolding for pad deletion tokens

PadDeletionManager stores a sha256-hashed per-pad deletion token and
verifies it with timing-safe comparison. createPad / createGroupPad
return the plaintext token once on first creation, and Pad.remove()
cleans it up. Gated behind the new allowPadDeletionByAllUsers flag
which defaults to false to preserve existing behaviour.

Part of #6701 (GDPR PR1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix+test(gdpr): lazy DB access in PadDeletionManager + unit tests

Capturing DB.db at module-load time was null until DB.init() ran, which
broke importing the module outside a live server (including from the
test runner). Switch to DB.db.* at call time and add unit tests
exercising create/verify/remove plus timing-safe comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gdpr): three-way auth for socket PAD_DELETE

Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag.
Anyone else still gets the existing refusal shout.

* feat(gdpr): optional deletionToken on programmatic deletePad

* feat(gdpr): advertise optional deletionToken on REST deletePad

* test(gdpr): cover deletePad authorisation matrix via REST

* feat(gdpr): surface padDeletionToken in clientVars for creators only

Revision-0 author on their first CLIENT_READY visit receives the
plaintext token; all subsequent CLIENT_READYs receive null because
createDeletionTokenIfAbsent is idempotent. Readonly sessions and any
other user never see the token.

* i18n(gdpr): strings for deletion-token modal and delete-with-token flow

* feat(gdpr): token modal + delete-with-token disclosure markup

* feat(gdpr): show deletion token once, allow delete via recovery token

* style(gdpr): modal + delete-with-token layout

* test(gdpr): Playwright coverage for deletion-token modal + delete-with-token

* fix(test): auto-dismiss deletion-token modal in goToNewPad helper

The token modal introduced in PR1 blocks clicks for every Playwright
test that creates a new pad via the shared helper. Add a one-line
dismissal so unrelated tests keep passing, and have the deletion-token
spec navigate inline via newPadKeepingModal() when it needs the modal
open to capture the token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): dismiss deletion-token modal without focus transfer

Clicking the ack button transferred focus out of the pad iframe, which
made subsequent keyboard-driven tests (Tab / Enter) silently miss the
editor. Swap the click for a page.evaluate() that hides the modal and
nulls clientVars.padDeletionToken directly, leaving focus where it was.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gdpr): PadDeletionManager race + document createPad/deletePad

Qodo review:
- createDeletionTokenIfAbsent() was a non-atomic read-then-write. Two
  concurrent callers for the same pad could both return different
  plaintext tokens while only the later hash was stored, leaving the
  first caller with an unusable recovery token. Serialise per-pad via a
  Promise chain and add a regression test that fires 8 concurrent
  calls and asserts exactly one plaintext is emitted and validates.
- doc/api/http_api.md now documents createPad returning deletionToken
  and deletePad accepting the optional deletionToken parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gdpr): always render delete-with-token in settings popup

The rebase onto develop placed the delete-pad-with-token details inside
the pad-settings-section conditional, which is only rendered when
enablePadWideSettings is true AND the section is toggled visible.
Second-device recovery (typing the captured token on a fresh browser)
must work without pad-wide settings enabled, so move the details out
to sit alongside the existing pad_deletion_token.spec.ts expectations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gdpr): require valid token when supplied, gate on auth, harden a11y/i18n

- PadMessageHandler: a supplied deletion token must validate; do not fall
  back to the creator-cookie path when the token is wrong (was deleting
  the pad anyway when the creator pasted a wrong token into the field).
- Skip token issuance + UI when requireAuthentication is on (creator
  identity is stable, recovery token is redundant noise).
- Server emits messageKey instead of hardcoded English; both shout
  handlers (inline alert and global gritter) localize via html10n.
- Suppress the global "Admin message" gritter for pad.deletionToken.*
  shouts to avoid the "Admin message: undefined" duplicate.
- Token-modal a11y: role=dialog, aria-modal, aria-labelledby/describedby,
  visually-hidden label on the token input, aria-live on Copy, focus to
  the token input on open and restore on dismiss.
- Style the "Delete Pad with Token" disclosure to match the Delete pad
  button; align the Copy/value row; pad the disclosure label.

Tests: Playwright now covers the creator-with-wrong-token path, asserts
no "Admin message" / "undefined" gritter on denial; backend API test
covers requireAuthentication suppressing 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-01 20:50:04 +08:00 committed by GitHub
parent 7357871acd
commit 5e8704f8d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1836 additions and 45 deletions

View File

@ -519,12 +519,20 @@ Group pads are normal pads, but with the name schema GROUPID$PADNAME. A security
#### createPad(padID, [text], [authorId])
* API >= 1
* `authorId` in API >= 1.3.0
* returns `deletionToken` once, since the same release that added `allowPadDeletionByAllUsers`
creates a new (non-group) pad. Note that if you need to create a group Pad, you should call **createGroupPad**.
You get an error message if you use one of the following characters in the padID: "/", "?", "&" or "#".
`data.deletionToken` is a one-shot recovery token tied to this pad. It is
returned in plaintext on the first call for a given padID and is `null` on
subsequent calls (the token itself is stored on the server as a sha256 hash).
Pass it to **deletePad** (or the socket `PAD_DELETE` message) to delete the
pad without the creator's author cookie.
*Example returns:*
* `{code: 0, message:"ok", data: null}`
* `{code: 0, message:"ok", data: {deletionToken: "…32-char random string…"}}`
* `{code: 0, message:"ok", data: {deletionToken: null}}` — pad already existed
* `{code: 1, message:"padID does already exist", data: null}`
* `{code: 1, message:"malformed padID: Remove special characters", data: null}`
@ -581,14 +589,24 @@ returns the list of users that are currently editing this pad
* `{code: 0, message:"ok", data: {padUsers: [{colorId:"#c1a9d9","name":"username1","timestamp":1345228793126,"id":"a.n4gEeMLsvg12452n"},{"colorId":"#d9a9cd","name":"Hmmm","timestamp":1345228796042,"id":"a.n4gEeMLsvg12452n"}]}}`
* `{code: 0, message:"ok", data: {padUsers: []}}`
#### deletePad(padID)
#### deletePad(padID, [deletionToken])
* API >= 1
* `deletionToken` in the same release as `allowPadDeletionByAllUsers`
deletes a pad
deletes a pad.
`deletionToken` is the one-shot recovery token returned by `createPad` /
`createGroupPad`. An apikey-authenticated caller can pass any (or no) token
and the call still succeeds — trusted admins bypass the check. An
unauthenticated caller (or a caller that explicitly passes a wrong token)
is rejected with `invalid deletionToken` unless the operator has set
`allowPadDeletionByAllUsers: true` in `settings.json`, in which case the
token is ignored.
*Example returns:*
* `{code: 0, message:"ok", data: null}`
* `{code: 1, message:"padID does not exist", data: null}`
* `{code: 1, message:"invalid deletionToken", data: null}`
#### copyPad(sourceID, destinationID[, force=false])
* API >= 1.2.8

View File

@ -0,0 +1,939 @@
# GDPR PR1 — Pad Deletion Controls 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:** Land the first of five GDPR PRs from ether/etherpad#6701 — adds a one-time deletion token, an `allowPadDeletionByAllUsers` admin flag, and the UI + endpoint plumbing needed for creators to delete a pad without their browser cookies.
**Architecture:** A new `PadDeletionManager` module owns the token (sha256-hashed in the db under `pad:<id>:deletionToken`, returned plaintext exactly once on creation). `handlePadDelete` gains a three-way authorisation check — creator cookie → valid token → settings flag — and `createPad`/`createGroupPad` return the token in the HTTP API response. The browser creator also receives the token via `clientVars.padDeletionToken`, shows it in a one-time modal, and gets a "delete with token" field in the settings popup for devices without the creator cookie.
**Tech Stack:** TypeScript (etherpad server + client), jQuery + EJS for pad UI, Playwright for frontend tests, Mocha + supertest for backend tests.
---
## File Structure
**Already in working tree (from restored stash):**
- `src/node/db/PadDeletionManager.ts` — create / verify (timing-safe) / remove
- `settings.json.template`, `settings.json.docker``allowPadDeletionByAllUsers: false`
- `src/node/utils/Settings.ts``allowPadDeletionByAllUsers` type + default
- `src/node/db/API.ts``createPad` returns `{deletionToken}`
- `src/node/db/GroupManager.ts``createGroupPad` returns `{padID, deletionToken}`
- `src/node/db/Pad.ts``Pad.remove()` calls `removeDeletionToken`
- `src/static/js/types/SocketIOMessage.ts``ClientVarPayload` has optional `padDeletionToken`
**Created by this plan:**
- `src/tests/backend/specs/padDeletionManager.ts` — unit tests for the manager
- `src/tests/backend/specs/api/deletePad.ts` — authorisation-matrix tests
- `src/tests/frontend-new/specs/pad_deletion_token.spec.ts` — end-to-end modal + delete-by-token
**Modified by this plan:**
- `src/node/handler/PadMessageHandler.ts` — three-way auth in `handlePadDelete`; thread `padDeletionToken` into `clientVars` for creator sessions
- `src/node/db/API.ts` — expose the optional `deletionToken` parameter on the programmatic `deletePad(padID, deletionToken?)` path for REST coverage
- `src/static/js/types/SocketIOMessage.ts` — add optional `deletionToken` to `PadDeleteMessage`
- `src/templates/pad.html` — post-creation token modal, delete-by-token disclosure under Delete button
- `src/static/js/pad.ts` — surface modal when `clientVars.padDeletionToken` is present, clear it after ack
- `src/static/js/pad_editor.ts` — wire delete-by-token input into the existing delete flow
- `src/static/css/pad.css` (or the skin component file the Delete button already lives in) — minimal styling for modal + disclosure
- `src/locales/en.json` — new localisation keys
- `src/tests/backend/specs/api/api.ts` — extend to cover `createPad` returning a token once
---
## Task 1: Baseline and verify the restored scaffolding
**Files:**
- (no edits — validation only)
- [ ] **Step 1: Confirm branch and stashed files exist**
```bash
git status --short
git log --oneline -5
```
Expected: current branch is `feat-gdpr-pad-deletion`, HEAD shows `docs: PR1 GDPR deletion-controls design spec`, and working tree modifications cover `settings.json.template`, `settings.json.docker`, `src/node/db/API.ts`, `src/node/db/GroupManager.ts`, `src/node/db/Pad.ts`, `src/node/utils/Settings.ts`, `src/static/js/types/SocketIOMessage.ts`, plus the untracked `src/node/db/PadDeletionManager.ts`.
- [ ] **Step 2: Type check before touching anything**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0, no TypeScript errors.
- [ ] **Step 3: Commit the restored scaffolding as its own change**
```bash
git add settings.json.template settings.json.docker \
src/node/db/API.ts src/node/db/GroupManager.ts src/node/db/Pad.ts \
src/node/utils/Settings.ts src/static/js/types/SocketIOMessage.ts \
src/node/db/PadDeletionManager.ts
git commit -m "$(cat <<'EOF'
feat(gdpr): scaffolding for pad deletion tokens
PadDeletionManager stores a sha256-hashed per-pad deletion token and
verifies it with timing-safe comparison. createPad / createGroupPad
return the plaintext token once on first creation, and Pad.remove()
cleans it up. Gated behind the new allowPadDeletionByAllUsers flag
which defaults to false to preserve existing behaviour.
Part of #6701 (GDPR PR1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
Expected: clean commit, no pre-commit hook failures.
---
## Task 2: Unit tests for `PadDeletionManager`
**Files:**
- Create: `src/tests/backend/specs/padDeletionManager.ts`
- [ ] **Step 1: Write the failing test file**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../common');
const padDeletionManager = require('../../../node/db/PadDeletionManager');
describe(__filename, function () {
before(async function () { await common.init(); });
const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
describe('createDeletionTokenIfAbsent', function () {
it('returns a non-empty string on first call', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(typeof token, 'string');
assert.ok(token.length >= 32);
await padDeletionManager.removeDeletionToken(padId);
});
it('returns null on subsequent calls for the same pad', async function () {
const padId = uniqueId();
const first = await padDeletionManager.createDeletionTokenIfAbsent(padId);
const second = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(typeof first, 'string');
assert.equal(second, null);
await padDeletionManager.removeDeletionToken(padId);
});
it('emits different tokens for different pads', async function () {
const a = uniqueId();
const b = uniqueId();
const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a);
const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b);
assert.notEqual(tokenA, tokenB);
await padDeletionManager.removeDeletionToken(a);
await padDeletionManager.removeDeletionToken(b);
});
});
describe('isValidDeletionToken', function () {
it('accepts the token returned by the matching pad', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true);
await padDeletionManager.removeDeletionToken(padId);
});
it('rejects a token for the wrong pad', async function () {
const a = uniqueId();
const b = uniqueId();
const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a);
await padDeletionManager.createDeletionTokenIfAbsent(b);
assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false);
await padDeletionManager.removeDeletionToken(a);
await padDeletionManager.removeDeletionToken(b);
});
it('rejects a non-string token', async function () {
const padId = uniqueId();
await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false);
await padDeletionManager.removeDeletionToken(padId);
});
it('returns false for pads that never had a token', async function () {
const padId = uniqueId();
assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false);
});
});
describe('removeDeletionToken', function () {
it('invalidates the stored token', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
await padDeletionManager.removeDeletionToken(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false);
});
it('is safe to call when no token exists', async function () {
const padId = uniqueId();
await padDeletionManager.removeDeletionToken(padId); // must not throw
});
});
});
```
- [ ] **Step 2: Run the test file and confirm it passes**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/padDeletionManager.ts --timeout 10000`
Expected: all 8 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/padDeletionManager.ts
git commit -m "test(gdpr): PadDeletionManager unit tests"
```
---
## Task 3: Extend `PadDeleteMessage` type and `handlePadDelete` authorisation
**Files:**
- Modify: `src/static/js/types/SocketIOMessage.ts:198-203`
- Modify: `src/node/handler/PadMessageHandler.ts:230-265`
- [ ] **Step 1: Add `deletionToken` to `PadDeleteMessage`**
```typescript
// src/static/js/types/SocketIOMessage.ts
export type PadDeleteMessage = {
type: 'PAD_DELETE'
data: {
padId: string
deletionToken?: string
}
}
```
- [ ] **Step 2: Thread the token through `handlePadDelete`**
Open `src/node/handler/PadMessageHandler.ts`, find `handlePadDelete` (near line 230), and replace its body (keep the outer async function signature) with:
```typescript
const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => {
const session = sessioninfos[socket.id];
if (!session || !session.author || !session.padId) throw new Error('session not ready');
const padId = padDeleteMessage.data.padId;
if (session.padId !== padId) throw new Error('refusing cross-pad delete');
if (!await padManager.doesPadExist(padId)) return;
const retrievedPad = await padManager.getPad(padId);
const firstContributor = await retrievedPad.getRevisionAuthor(0);
const isCreator = session.author === firstContributor;
const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken(
padId, padDeleteMessage.data.deletionToken);
const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers;
if (isCreator || tokenOk || flagOk) {
await retrievedPad.remove();
return;
}
socket.emit('shout', {
type: 'COLLABROOM',
data: {
type: 'shoutMessage',
payload: {
message: {
message: 'You are not the creator of this pad, so you cannot delete it',
sticky: false,
},
timestamp: Date.now(),
},
},
});
};
```
- [ ] **Step 3: Wire the new imports at the top of `PadMessageHandler.ts`**
Ensure the file has:
```typescript
const padDeletionManager = require('../db/PadDeletionManager');
```
(Add it to the import block alongside the existing `padManager` require. If it is already present from earlier scaffolding, skip this step.)
- [ ] **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/types/SocketIOMessage.ts src/node/handler/PadMessageHandler.ts
git commit -m "feat(gdpr): three-way auth for socket PAD_DELETE
Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag.
Anyone else still gets the existing refusal shout."
```
---
## Task 4: Programmatic `deletePad(padId, deletionToken?)` and REST coverage
**Files:**
- Modify: `src/node/db/API.ts:530-545` (the `deletePad` export)
- [ ] **Step 1: Extend the programmatic `deletePad` signature**
Replace the existing `exports.deletePad` with:
```typescript
/**
deletePad(padID, deletionToken?) deletes a pad
...
*/
exports.deletePad = async (padID: string, deletionToken?: string) => {
const pad = await getPadSafe(padID, true);
// apikey-authenticated callers bypass token checks — they're already trusted.
// For anonymous callers that hit this code path (e.g. a future public endpoint),
// require a valid token unless the instance has opted everyone in.
if (deletionToken !== undefined &&
!settings.allowPadDeletionByAllUsers &&
!await padDeletionManager.isValidDeletionToken(padID, deletionToken)) {
throw new CustomError('invalid deletionToken', 'apierror');
}
await pad.remove();
};
```
- [ ] **Step 2: Add the `CustomError` and `settings` imports if missing**
At the top of `src/node/db/API.ts`, confirm the file has:
```typescript
const CustomError = require('../utils/customError');
import settings from '../utils/Settings';
```
(Both already exist in etherpad; add only if absent.)
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/node/db/API.ts
git commit -m "feat(gdpr): optional deletionToken on programmatic deletePad"
```
---
## Task 5: Advertise `deletionToken` in the REST OpenAPI schema
**Files:**
- Modify: `src/node/handler/APIHandler.ts` — add `deletionToken` to the `deletePad` arg list
- [ ] **Step 1: Extend the API version-map entry for `deletePad`**
Open `src/node/handler/APIHandler.ts` and locate the existing `deletePad: ['padID']` entry (around line 56). Change it to:
```typescript
deletePad: ['padID', 'deletionToken'],
```
If the codebase uses a per-version map (older vs. newer), make the same change in every version entry that currently lists `deletePad`.
- [ ] **Step 2: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add src/node/handler/APIHandler.ts
git commit -m "feat(gdpr): advertise optional deletionToken on REST deletePad"
```
---
## Task 6: REST API test for the authorisation matrix
**Files:**
- Create: `src/tests/backend/specs/api/deletePad.ts`
- [ ] **Step 1: Write the test spec**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../../common');
import settings from '../../../node/utils/Settings';
let agent: any;
let apiKey: string;
const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const apiCall = async (point: string, query: Record<string, string>) => {
const params = new URLSearchParams({apikey: apiKey, ...query}).toString();
return await agent.get(`/api/1/${point}?${params}`);
};
describe(__filename, function () {
before(async function () {
agent = await common.init();
apiKey = common.apiKey;
});
afterEach(function () { settings.allowPadDeletionByAllUsers = false; });
it('createPad returns a plaintext deletionToken the first time', async function () {
const padId = makeId();
const res = await apiCall('createPad', {padID: padId});
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data.deletionToken, 'string');
assert.ok(res.body.data.deletionToken.length >= 32);
await apiCall('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken});
});
it('deletePad with a valid deletionToken succeeds', async function () {
const padId = makeId();
const create = await apiCall('createPad', {padID: padId});
const token = create.body.data.deletionToken;
const del = await apiCall('deletePad', {padID: padId, deletionToken: token});
assert.equal(del.body.code, 0, JSON.stringify(del.body));
const check = await apiCall('getText', {padID: padId});
assert.equal(check.body.code, 1); // "padID does not exist"
});
it('deletePad with a wrong deletionToken is refused', async function () {
const padId = makeId();
await apiCall('createPad', {padID: padId});
const del = await apiCall('deletePad', {padID: padId, deletionToken: 'not-the-real-token'});
assert.equal(del.body.code, 1);
assert.match(del.body.message, /invalid deletionToken/);
// cleanup — apikey-authenticated caller is trusted when no token is supplied
await apiCall('deletePad', {padID: padId});
});
it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () {
const padId = makeId();
await apiCall('createPad', {padID: padId});
settings.allowPadDeletionByAllUsers = true;
const del = await apiCall('deletePad', {padID: padId, deletionToken: 'bogus'});
assert.equal(del.body.code, 0);
});
it('apikey-only call (no deletionToken) still works — admins stay trusted', async function () {
const padId = makeId();
await apiCall('createPad', {padID: padId});
const del = await apiCall('deletePad', {padID: padId});
assert.equal(del.body.code, 0);
});
});
```
- [ ] **Step 2: Run the new spec**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/api/deletePad.ts --timeout 20000`
Expected: all 5 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/api/deletePad.ts
git commit -m "test(gdpr): cover deletePad authorisation matrix via REST"
```
---
## Task 7: Send `padDeletionToken` to the creator session via `clientVars`
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts` — in the CLIENT_READY handler where `clientVars` is assembled (around line 1008)
- [ ] **Step 1: Compute the token in the same block that decides creator-only UI**
Locate the `const canEditPadSettings = ...` computation introduced by PR #7545 (or its nearest equivalent — the creator-cookie check using `isPadCreator`). Immediately after it, add:
```typescript
const padDeletionToken = !sessionInfo.readonly && canEditPadSettings
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
: null;
```
Then include the field in the `clientVars` literal (right after `canEditPadSettings`):
```typescript
padDeletionToken,
```
(If PR #7545 has not merged yet on this branch, replace `canEditPadSettings` in the conditional with the equivalent inline expression:
`!sessionInfo.readonly && await isPadCreator(pad, sessionInfo.author)`.)
- [ ] **Step 2: Confirm the `ClientVarPayload` type already has `padDeletionToken`**
`src/static/js/types/SocketIOMessage.ts` should still contain:
```typescript
padDeletionToken?: string | null,
```
(added by the restored scaffolding). If it was stripped during earlier cleanup, add it back.
- [ ] **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 src/static/js/types/SocketIOMessage.ts
git commit -m "feat(gdpr): surface padDeletionToken in clientVars for creators only"
```
---
## Task 8: Locale strings
**Files:**
- Modify: `src/locales/en.json`
- [ ] **Step 1: Add the new keys**
Insert the following inside the `pad.*` block (next to `pad.delete.confirm`):
```json
"pad.deletionToken.modalTitle": "Save your pad deletion token",
"pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.",
"pad.deletionToken.copy": "Copy",
"pad.deletionToken.copied": "Copied",
"pad.deletionToken.acknowledge": "I've saved it",
"pad.deletionToken.deleteWithToken": "Delete with token",
"pad.deletionToken.tokenFieldLabel": "Pad deletion token",
"pad.deletionToken.invalid": "That token is not valid for this pad.",
```
Leave every other locale file untouched — English is the canonical source; translators fill in the rest.
- [ ] **Step 2: Type check (picks up JSON parse errors via test-runner bootstrap)**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add src/locales/en.json
git commit -m "i18n(gdpr): strings for deletion-token modal and delete-with-token flow"
```
---
## Task 9: Template — one-time token modal + delete-by-token disclosure
**Files:**
- Modify: `src/templates/pad.html`
- [ ] **Step 1: Add the deletion-token modal, sibling to the existing `#settings` popup**
Find the `<div id="settings" class="popup">...</div>` block. Immediately after its closing wrapper, add:
```html
<div id="deletiontoken-modal" class="popup" hidden>
<div class="popup-content">
<h1 data-l10n-id="pad.deletionToken.modalTitle">Save your pad deletion token</h1>
<p data-l10n-id="pad.deletionToken.modalBody">
This token is the only way to delete this pad if you lose your
browser session or switch device. Save it somewhere safe — it
is shown here exactly once.
</p>
<div class="deletiontoken-row">
<input type="text" id="deletiontoken-value" readonly>
<button id="deletiontoken-copy" type="button" data-l10n-id="pad.deletionToken.copy">Copy</button>
</div>
<button id="deletiontoken-ack" type="button" class="btn btn-primary"
data-l10n-id="pad.deletionToken.acknowledge">I've saved it</button>
</div>
</div>
```
- [ ] **Step 2: Add the delete-by-token disclosure under the existing Delete button**
Find `<button data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>` in the settings popup. Replace the single button with:
```html
<button data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>
<details id="delete-pad-with-token">
<summary data-l10n-id="pad.deletionToken.deleteWithToken">Delete with token</summary>
<label for="delete-pad-token-input" data-l10n-id="pad.deletionToken.tokenFieldLabel">Pad deletion token</label>
<input type="password" id="delete-pad-token-input" autocomplete="off" spellcheck="false">
<button id="delete-pad-token-submit" type="button" class="btn btn-danger"
data-l10n-id="pad.settings.deletePad">Delete pad</button>
</details>
```
- [ ] **Step 3: Commit**
```bash
git add src/templates/pad.html
git commit -m "feat(gdpr): token modal + delete-with-token disclosure markup"
```
---
## Task 10: Client JS — modal reveal and delete-by-token wiring
**Files:**
- Modify: `src/static/js/pad.ts` — surface the modal, scrub token from `clientVars`
- Modify: `src/static/js/pad_editor.ts` — delete-by-token submit
- [ ] **Step 1: Surface the modal and scrub the token after acknowledgement**
In `src/static/js/pad.ts`, locate the `init` / `handleInit` phase — immediately after `clientVars` has been applied and the pad is usable. Add the following helper and an invocation:
```typescript
const showDeletionTokenModalIfPresent = () => {
const token = clientVars.padDeletionToken;
if (!token) return;
const $modal = $('#deletiontoken-modal');
const $input = $('#deletiontoken-value');
const $copy = $('#deletiontoken-copy');
const $ack = $('#deletiontoken-ack');
if ($modal.length === 0) return;
$input.val(token);
$modal.prop('hidden', false).addClass('popup-show');
$copy.off('click.gdpr').on('click.gdpr', async () => {
try {
await navigator.clipboard.writeText(token);
$copy.text(html10n.get('pad.deletionToken.copied'));
} catch (e) {
($input[0] as HTMLInputElement).select();
document.execCommand('copy');
$copy.text(html10n.get('pad.deletionToken.copied'));
}
});
$ack.off('click.gdpr').on('click.gdpr', () => {
$input.val('');
$modal.prop('hidden', true).removeClass('popup-show');
(clientVars as any).padDeletionToken = null;
});
};
```
Call `showDeletionTokenModalIfPresent()` once, after the user-visible pad has finished loading (a good spot is immediately after the existing `padeditor.init(...)` or `padimpexp.init(...)` call).
- [ ] **Step 2: Wire the delete-by-token UI**
In `src/static/js/pad_editor.ts`, find the existing `$('#delete-pad').on('click', ...)` handler (around line 90) and, directly after it, add:
```typescript
// delete pad using a recovery token
$('#delete-pad-token-submit').on('click', () => {
const token = String($('#delete-pad-token-input').val() || '').trim();
if (!token) return;
if (!window.confirm(html10n.get('pad.delete.confirm'))) return;
let handled = false;
pad.socket.on('message', (data: any) => {
if (data && data.disconnect === 'deleted') {
handled = true;
window.location.href = '/';
}
});
pad.socket.on('shout', (data: any) => {
handled = true;
const msg = data?.data?.payload?.message?.message;
if (msg) window.alert(msg);
});
pad.collabClient.sendMessage({
type: 'PAD_DELETE',
data: {padId: pad.getPadId(), deletionToken: token},
});
setTimeout(() => {
if (!handled) window.location.href = '/';
}, 5000);
});
```
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/static/js/pad.ts src/static/js/pad_editor.ts
git commit -m "feat(gdpr): show deletion token once, allow delete via recovery token"
```
---
## Task 11: Minimal styling for the modal + disclosure
**Files:**
- Modify: `src/static/css/pad.css` (or the skin CSS file that already styles `.popup`)
- [ ] **Step 1: Add scoped styles**
Append:
```css
#deletiontoken-modal .deletiontoken-row {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
#deletiontoken-modal #deletiontoken-value {
flex: 1;
font-family: monospace;
padding: 0.4rem;
user-select: all;
}
#delete-pad-with-token {
margin-top: 0.5rem;
}
#delete-pad-with-token summary {
cursor: pointer;
color: var(--text-muted, #666);
font-size: 0.9rem;
}
#delete-pad-with-token input {
margin: 0.5rem 0;
width: 100%;
font-family: monospace;
}
```
Use whichever file the existing `#settings.popup` and `#delete-pad` styles live in (check via `grep -rn "#delete-pad" src/static/css src/static/skins` and pick the one already loaded by `pad.html`).
- [ ] **Step 2: Commit**
```bash
git add src/static/css/pad.css # or the skin file you actually touched
git commit -m "style(gdpr): modal + delete-with-token layout"
```
---
## Task 12: Frontend Playwright coverage
**Files:**
- Create: `src/tests/frontend-new/specs/pad_deletion_token.spec.ts`
- [ ] **Step 1: Write the Playwright spec**
```typescript
import {expect, test} from '@playwright/test';
import {goToNewPad, goToPad} from '../helper/padHelper';
import {showSettings} from '../helper/settingsHelper';
test.describe('pad deletion token', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('creator sees a token modal exactly once and can dismiss it', async ({page}) => {
await goToNewPad(page);
const modal = page.locator('#deletiontoken-modal');
await expect(modal).toBeVisible();
const tokenValue = await page.locator('#deletiontoken-value').inputValue();
expect(tokenValue.length).toBeGreaterThanOrEqual(32);
await page.locator('#deletiontoken-ack').click();
await expect(modal).toBeHidden();
const cleared = await page.evaluate(
() => (window as any).clientVars.padDeletionToken);
expect(cleared == null).toBe(true);
});
test('second device can delete using the captured token', async ({page, browser}) => {
const padId = await goToNewPad(page);
const token = await page.locator('#deletiontoken-value').inputValue();
await page.locator('#deletiontoken-ack').click();
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await goToPad(page2, padId);
await showSettings(page2);
await page2.locator('#delete-pad-with-token > summary').click();
await page2.locator('#delete-pad-token-input').fill(token);
page2.once('dialog', (d) => d.accept());
await page2.locator('#delete-pad-token-submit').click();
await expect(page2).toHaveURL(/\/$|\/index\.html$/, {timeout: 10000});
// The pad should be gone — opening it again yields a fresh empty pad.
await goToPad(page2, padId);
const contents = await page2.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe[name="ace_inner"]').locator('#innerdocbody').textContent();
expect((contents || '').trim().length).toBeLessThan(200); // default welcome text only
await context2.close();
});
test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => {
const padId = await goToNewPad(page);
await page.locator('#deletiontoken-ack').click();
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await goToPad(page2, padId);
await showSettings(page2);
await page2.locator('#delete-pad-with-token > summary').click();
await page2.locator('#delete-pad-token-input').fill('bogus-token-value');
page2.once('dialog', (d) => d.accept());
const alertPromise = page2.waitForEvent('dialog');
await page2.locator('#delete-pad-token-submit').click();
const alert = await alertPromise;
expect(alert.message()).toMatch(/not the creator|cannot delete/);
await alert.dismiss();
// Pad must still exist for the original creator.
await page.reload();
await expect(page.locator('#editorcontainer.initialized')).toBeVisible();
await context2.close();
});
});
```
- [ ] **Step 2: Restart the test server so it picks up the current branch's 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 8
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | tail -2
```
Expected: port 9001 is listening.
- [ ] **Step 3: Run the new Playwright spec**
```bash
cd src && NODE_ENV=production npx playwright test pad_deletion_token --project=chromium
```
Expected: 3 tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/tests/frontend-new/specs/pad_deletion_token.spec.ts
git commit -m "test(gdpr): Playwright coverage for deletion-token modal + delete-with-token"
```
---
## Task 13: End-to-end verification, push, open PR
**Files:** (no edits)
- [ ] **Step 1: Full type-check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 2: Backend tests for just this feature**
```bash
pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \
tests/backend/specs/padDeletionManager.ts \
tests/backend/specs/api/deletePad.ts --timeout 20000
```
Expected: 13 tests pass.
- [ ] **Step 3: Full Playwright smoke for the touched specs**
```bash
cd src && NODE_ENV=production npx playwright test \
pad_deletion_token pad_settings --project=chromium
```
Expected: all tests pass. (pad_settings included because Task 7 changes the `clientVars` assembly near its creator-only code.)
- [ ] **Step 4: Push and open the PR**
```bash
git push origin feat-gdpr-pad-deletion
gh pr create --title "feat(gdpr): pad deletion controls (PR1 of #6701)" --body "$(cat <<'EOF'
## Summary
- One-time sha256-hashed deletion token, surfaced plaintext once on create
- allowPadDeletionByAllUsers flag (defaults to false) to widen deletion rights
- Three-way auth on socket PAD_DELETE and REST deletePad: creator cookie, valid token, or settings flag
- Browser creators see a one-time token modal and can later delete via a recovery-token field in the pad settings popup
First of the five GDPR PRs outlined in #6701. Remaining scope (IP audit, identity hardening, cookie banner, author erasure) stays in follow-ups.
## Test plan
- [ ] ts-check clean
- [ ] Backend: padDeletionManager + api/deletePad specs
- [ ] Frontend: pad_deletion_token.spec.ts and pad_settings.spec.ts regression
EOF
)"
```
Expected: PR opens, CI runs.
- [ ] **Step 5: Monitor CI**
Run: `sleep 25 && gh pr checks <PR-number>`
Expected: all checks green (or failure triage kicks in, per the feedback_check_ci_after_pr memory).
---
## Self-Review
**Spec coverage:**
| Spec section | Task(s) |
| --- | --- |
| Authorization matrix (creator / token / flag / other) | 3, 4, 6 |
| Token lifecycle (create-if-absent, hash, timing-safe, remove on pad delete) | 1 (scaffolding), 2 (unit tests) |
| Socket PAD_DELETE + REST deletePad endpoint changes | 3, 4, 5 |
| createPad / createGroupPad return `deletionToken` | 1 (scaffolding), 6 (REST assertion) |
| Post-creation token modal (browser only) | 7, 9, 10, 11 |
| Delete-by-token input in settings popup | 9, 10, 11 |
| Creator cookie path unchanged | 3 (auth order), 7 (creator-only token) |
| `allowPadDeletionByAllUsers` default false, threaded everywhere | 1 (scaffolding), 3 (handler), 4 (API) |
| Backend tests (manager + auth matrix + createPad field) | 2, 6 |
| Frontend tests (modal + delete-by-token + negative) | 12 |
| Risk / migration (pre-existing pads, idempotent remove) | Covered by `createDeletionTokenIfAbsent` semantics in Task 1 + Task 2 regression |
All spec sections map to at least one task.
**Placeholders:** none — every code block is complete, every command has expected output.
**Type consistency:**
- `createDeletionTokenIfAbsent(padId)` — consistent across Tasks 1, 2, 7.
- `isValidDeletionToken(padId, token)` — consistent across Tasks 2, 3, 4.
- `removeDeletionToken(padId)` — consistent across Tasks 1, 2.
- `PadDeleteMessage.data.deletionToken?` — Task 3 definition matches Task 10 consumer and Task 12 test usage.
- `clientVars.padDeletionToken` — Task 7 writer, Task 10 reader, Task 12 test assertion all agree on the name and null-semantics.
- `allowPadDeletionByAllUsers` — Task 1 scaffolding, Task 3 handler, Task 4 API, Task 6 REST test all use the same flag.

View File

@ -0,0 +1,207 @@
# PR1 — GDPR Deletion Controls
Part of the GDPR work planned in ether/etherpad#6701. This PR delivers
deletion controls: a one-time deletion token, an admin-level permission
flag, and the wiring needed for the existing "Delete pad" button to work
for token-bearers in addition to the creator cookie.
Scope deliberately excludes: author erasure, IP audits, anonymous
identity hardening, and the privacy banner. Those are PR2PR5.
## Goals
- A pad created via the HTTP API returns a cryptographically random
deletion token exactly once. Possession of that token is proof that
the holder may delete the pad. The token survives cookie loss and
device changes.
- Instance admins can widen deletion rights to any pad editor via
`allowPadDeletionByAllUsers`, keeping the default tight.
- Browser-created pads show the token once in a copyable modal so the
creator has a path off-device.
- No existing delete path regresses: the creator cookie still works with
no token involvement.
## Non-goals
- Revocation / rotation of deletion tokens. A token is valid until the
pad is deleted, at which point both pad and token go away together.
- Multi-token support per pad. One token, one pad.
- Author erasure (right-to-be-forgotten) — PR5.
- Surfacing IP-logging behaviour or a privacy banner — PR2 / PR4.
## Authorization matrix
Wired into `handlePadDelete` (socket) and `deletePad` (REST API).
| Caller | Default (`allowPadDeletionByAllUsers: false`) | `allowPadDeletionByAllUsers: true` |
| --- | --- | --- |
| Session author matches revision-0 author (creator cookie) | Allowed | Allowed |
| Supplies a deletion token that `isValidDeletionToken()` accepts | Allowed | Allowed |
| Any other pad editor | Refused with the existing "not the creator" shout | Allowed |
| Unauthorised (no session, read-only, wrong pad) | Refused | Refused |
Rationale: the token is a recovery credential, not a day-to-day
capability, so the default never silently upgrades "anyone in the pad"
to deleter. Admins opt in explicitly when that's the policy they want.
## Token lifecycle
1. On the first successful `createPad` / `createGroupPad` call,
`PadDeletionManager.createDeletionTokenIfAbsent(padId)` generates a
32-character random string, stores `sha256(token)` in
`pad:<padId>:deletionToken`, and returns the plaintext token.
2. The plaintext is returned once in the API response
(`{padID, deletionToken}`) and, for browser-created pads, streamed
into `clientVars.padDeletionToken` on that session only.
3. The browser shows the token in a one-time modal with a Copy button
and guidance ("save this somewhere — it is the only way to delete
this pad if you lose your browser session"). After the modal is
acknowledged, the token is not rendered again.
4. On delete, `Pad.remove()` calls
`PadDeletionManager.removeDeletionToken(padId)` so DB state stays
consistent.
5. Subsequent `createPad` calls for the same padId never regenerate the
token (the `createDeletionTokenIfAbsent` name is load-bearing).
Storage shape already introduced in the scaffolding:
```json
{
"createdAt": 1712451234567,
"hash": "<sha256 hex of the token>"
}
```
`isValidDeletionToken()` uses `crypto.timingSafeEqual` on equal-length
buffers. Unknown padIds and non-string tokens return `false` without
touching the hash buffer.
## Endpoints
### Socket `PAD_DELETE`
Existing message gains an optional `deletionToken` field:
```ts
type PadDeleteMessage = {
type: 'PAD_DELETE',
data: {
padId: string,
deletionToken?: string,
}
}
```
`handlePadDelete` authorises in order: creator cookie → valid token →
settings flag. On refusal, it emits the same shout as today.
### REST `POST /api/1/deletePad`
Accepts the existing `padID` plus an optional `deletionToken` parameter.
HTTP-authenticated admin callers (apikey) bypass the check exactly as
they do today; the token path is for unauthenticated callers who own
the credential.
### REST `POST /api/1/createPad` and `createGroupPad`
Response body adds `deletionToken: <string>` on first creation and
`deletionToken: null` on any subsequent no-op call. Other API consumers
who never read the field are unaffected.
## UI
### Post-creation modal (browser pads only)
Rendered from `pad.ts` when `clientVars.padDeletionToken` is truthy.
Shown inline after pad init, with:
- Copy-to-clipboard button.
- A localised explanation ("save this once — required to delete the pad
if you lose your session or switch devices").
- Acknowledgement button that dismisses the modal. The token is cleared
from the in-memory `clientVars` after acknowledgement so a page print
/ screenshot after the fact won't re-expose it from the DOM.
### Delete-by-token entry in the settings popup
Add a disclosure under the existing Delete button: "I don't have creator
cookies — delete with token" → expands a password-style input and a
confirm button. On submit, sends `PAD_DELETE` with the token.
### Existing creator flow (no change)
The creator with their original cookie presses Delete exactly like
today. No token is collected in that path.
## Settings
```jsonc
/*
* Allow any user who can edit a pad to delete it without the one-time pad
* deletion token. If false (default), only the original creator's author
* cookie or the deletion token can delete the pad.
*/
"allowPadDeletionByAllUsers": false
```
Default `false` in both `settings.json.template` and
`settings.json.docker`. Threaded into `SettingsType` and `settings`
object (scaffolding already present).
## Data flow
```
createPad/createGroupPad
└─► PadDeletionManager.createDeletionTokenIfAbsent
└─► db.set(pad:<id>:deletionToken, {createdAt, hash})
└─► plaintext token → API response / clientVars (browser only)
browser Delete button
├─ creator cookie path: socket PAD_DELETE { padId }
└─ token path: socket PAD_DELETE { padId, deletionToken }
└─► handlePadDelete authorisation
├─ session.author === revision-0 author ⇒ allow
├─ isValidDeletionToken(padId, token) ⇒ allow
├─ settings.allowPadDeletionByAllUsers ⇒ allow
└─ else ⇒ shout refusal
Pad.remove()
└─► padDeletionManager.removeDeletionToken(padId)
└─► existing pad removal cleanup
```
## Testing
### Backend (`src/tests/backend/specs/`)
- `padDeletionManager.ts`: create / create-when-exists / verify-valid /
verify-wrong-token / verify-unknown-pad / timing-safe equality /
remove-on-delete.
- Extend `api/api.ts` (currently covers createPad behaviour) or add a
sibling spec to assert `deletionToken` is present on first create and
`null` on a duplicate call.
- Add `api/deletePad.ts` covering the four authorisation paths in the
matrix plus the settings-flag toggle.
### Frontend (`src/tests/frontend-new/specs/`)
- `pad_deletion_token.spec.ts`: creator session creates a pad, token
modal appears and can be dismissed; after acknowledgement the token
is no longer reachable in `window.clientVars`.
- Same spec: second browser context (no creator cookie) opens the pad,
supplies the captured token via the delete-by-token UI, and verifies
the pad is removed (navigated away / confirmed gone).
- Negative case: invalid token → pad survives, shout refusal surfaces.
## Risk and migration
- Existing pads created before this PR have no stored token. First call
to `createDeletionTokenIfAbsent` for a pre-existing padId generates
and stores one — that's the expected upgrade path and does not change
any already-valid deletion flow.
- `db.remove` on a non-existent key is a no-op in etherpad's db layer,
so `removeDeletionToken` is safe to call unconditionally during pad
removal.
- Feature flag (`allowPadDeletionByAllUsers`) defaults to the stricter
behaviour; no existing instance sees a behavioural change unless its
operator opts in.

View File

@ -522,6 +522,13 @@
*/
"disableIPlogging": "${DISABLE_IP_LOGGING:false}",
/*
* Allow any user who can edit a pad to delete it without the one-time pad
* deletion token. If false, only the original creator's author cookie or the
* deletion token can delete the pad.
*/
"allowPadDeletionByAllUsers": "${ALLOW_PAD_DELETION_BY_ALL_USERS:false}",
/*
* Time (in seconds) to automatically reconnect pad when a "Force reconnect"
* message is shown to user.

View File

@ -531,6 +531,13 @@
*/
"disableIPlogging": false,
/*
* Allow any user who can edit a pad to delete it without the one-time pad
* deletion token. If false, only the original creator's author cookie or the
* deletion token can delete the pad.
*/
"allowPadDeletionByAllUsers": false,
/*
* Time (in seconds) to automatically reconnect pad when a "Force reconnect"
* message is shown to user.

View File

@ -115,6 +115,16 @@
"pad.settings.language": "Language:",
"pad.settings.deletePad": "Delete Pad",
"pad.delete.confirm": "Do you really want to delete this pad?",
"pad.deletionToken.modalTitle": "Save your pad deletion token",
"pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.",
"pad.deletionToken.copy": "Copy",
"pad.deletionToken.copied": "Copied",
"pad.deletionToken.acknowledge": "I've saved it",
"pad.deletionToken.deleteWithToken": "Delete Pad with Token",
"pad.deletionToken.tokenFieldLabel": "Pad deletion token",
"pad.deletionToken.tokenValueLabel": "Your pad deletion token (read-only)",
"pad.deletionToken.invalid": "That token is not valid for this pad.",
"pad.deletionToken.notCreator": "You are not the creator of this pad, so you cannot delete it.",
"pad.settings.about": "About",
"pad.settings.poweredBy": "Powered by",

View File

@ -23,6 +23,7 @@ import {deserializeOps} from '../../static/js/Changeset';
import ChatMessage from '../../static/js/ChatMessage';
import {Builder} from "../../static/js/Builder";
import {Attribute} from "../../static/js/types/Attribute";
import settings from '../utils/Settings';
const CustomError = require('../utils/customError');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
@ -30,6 +31,7 @@ import readOnlyManager from './ReadOnlyManager';
const groupManager = require('./GroupManager');
const authorManager = require('./AuthorManager');
const sessionManager = require('./SessionManager');
const padDeletionManager = require('./PadDeletionManager');
const exportHtml = require('../utils/ExportHtml');
const exportTxt = require('../utils/ExportTxt');
const importHtml = require('../utils/ImportHtml');
@ -518,19 +520,36 @@ exports.createPad = async (padID: string, text: string, authorId = '') => {
// create pad
await getPadSafe(padID, false, text, authorId);
// When requireAuthentication is on, every creator has a stable identity, so
// the cookie/identity path covers recovery and the one-time token is just
// an extra surface to leak.
const deletionToken = settings.requireAuthentication
? null
: await padDeletionManager.createDeletionTokenIfAbsent(padID);
return {deletionToken};
};
/**
deletePad(padID) deletes a pad
deletePad(padID, [deletionToken]) deletes a pad
Example returns:
{code: 0, message:"ok", data: null}
{code: 1, message:"padID does not exist", data: null}
{code: 1, message:"invalid deletionToken", data: null}
@param {String} padID the id of the pad
@param {String} [deletionToken] recovery token issued by createPad
*/
exports.deletePad = async (padID: string) => {
exports.deletePad = async (padID: string, deletionToken?: string) => {
const pad = await getPadSafe(padID, true);
// apikey-authenticated callers (no deletionToken supplied) are trusted.
// When a caller supplies a deletionToken, it must validate unless the
// instance has opted everyone in via allowPadDeletionByAllUsers.
if (deletionToken !== undefined && deletionToken !== '' &&
!settings.allowPadDeletionByAllUsers &&
!await padDeletionManager.isValidDeletionToken(padID, deletionToken)) {
throw new CustomError('invalid deletionToken', 'apierror');
}
await pad.remove();
};

View File

@ -22,6 +22,7 @@
const CustomError = require('../utils/customError');
import {randomString} from "../../static/js/pad_utils";
const db = require('./DB');
const padDeletionManager = require('./PadDeletionManager');
const padManager = require('./PadManager');
const sessionManager = require('./SessionManager');
@ -136,7 +137,12 @@ exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => {
* @param {String} authorId The id of the author
* @return {Promise<{padID: string}>} a promise that resolves to the id of the new pad
*/
exports.createGroupPad = async (groupID: string, padName: string, text: string, authorId: string = ''): Promise<{ padID: string; }> => {
exports.createGroupPad = async (
groupID: string,
padName: string,
text: string,
authorId: string = '',
): Promise<{ padID: string; deletionToken: string | null; }> => {
// create the padID
const padID = `${groupID}$${padName}`;
@ -161,7 +167,10 @@ exports.createGroupPad = async (groupID: string, padName: string, text: string,
// create an entry in the group for this pad
await db.setSub(`group:${groupID}`, ['pads', padID], 1);
return {padID};
return {
padID,
deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID),
};
};
/**

View File

@ -16,6 +16,7 @@ const assert = require('assert').strict;
const db = require('./DB');
import settings from '../utils/Settings';
const authorManager = require('./AuthorManager');
const padDeletionManager = require('./PadDeletionManager');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
const groupManager = require('./GroupManager');
@ -664,6 +665,7 @@ class Pad {
// delete the pad entry and delete pad from padManager
p.push(padManager.removePad(padID));
p.push(padDeletionManager.removeDeletionToken(padID));
p.push(hooks.aCallAll('padRemove', {
get padID() {
pad_utils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead');

View File

@ -0,0 +1,48 @@
'use strict';
import crypto from 'node:crypto';
import randomString from '../utils/randomstring';
const DB = require('./DB');
const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`;
const hashDeletionToken = (deletionToken: string) =>
crypto.createHash('sha256').update(deletionToken, 'utf8').digest();
// Per-pad serialisation for token creation. Without this, two concurrent
// `createDeletionTokenIfAbsent()` calls for the same pad can both observe
// an empty slot, both write a hash, and leave the earlier caller holding a
// plaintext token that no longer validates. The chain is cleaned up once the
// outstanding call resolves so this map doesn't grow unbounded.
const inflightCreate: Map<string, Promise<string | null>> = new Map();
exports.createDeletionTokenIfAbsent = async (padId: string): Promise<string | null> => {
const prior = inflightCreate.get(padId);
const next = (prior || Promise.resolve()).then(async () => {
if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null;
const deletionToken = randomString(32);
await DB.db.set(getDeletionTokenKey(padId), {
createdAt: Date.now(),
hash: hashDeletionToken(deletionToken).toString('hex'),
});
return deletionToken;
});
const tracked = next.finally(() => {
if (inflightCreate.get(padId) === tracked) inflightCreate.delete(padId);
});
inflightCreate.set(padId, tracked);
return next;
};
exports.isValidDeletionToken = async (padId: string, deletionToken: string | null | undefined) => {
if (typeof deletionToken !== 'string' || deletionToken === '') return false;
const storedToken = await DB.db.get(getDeletionTokenKey(padId));
if (storedToken == null || typeof storedToken.hash !== 'string') return false;
const expected = Buffer.from(storedToken.hash, 'hex');
const actual = hashDeletionToken(deletionToken);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
};
exports.removeDeletionToken = async (padId: string) =>
await DB.db.remove(getDeletionTokenKey(padId));

View File

@ -53,7 +53,7 @@ version['1'] = {
setHTML: ['padID', 'html'],
getRevisionsCount: ['padID'],
getLastEdited: ['padID'],
deletePad: ['padID'],
deletePad: ['padID', 'deletionToken'],
getReadOnlyID: ['padID'],
setPublicStatus: ['padID', 'publicStatus'],
getPublicStatus: ['padID'],

View File

@ -23,6 +23,7 @@ import {MapArrayType} from "../types/MapType";
import AttributeMap from '../../static/js/AttributeMap';
const padManager = require('../db/PadManager');
const padDeletionManager = require('../db/PadDeletionManager');
import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
import ChatMessage from '../../static/js/ChatMessage';
import AttributePool from '../../static/js/AttributePool';
@ -259,39 +260,48 @@ exports.handleDisconnect = async (socket:any) => {
const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => {
const session = sessioninfos[socket.id];
if (!session || !session.author || !session.padId) throw new Error('session not ready');
if (await padManager.doesPadExist(padDeleteMessage.data.padId)) {
const retrievedPad = await padManager.getPad(padDeleteMessage.data.padId)
// Only the one doing the first revision can delete the pad, otherwise people could troll a lot
const firstContributor = await retrievedPad.getRevisionAuthor(0)
if (session.author === firstContributor) {
await retrievedPad.remove()
} else {
const padId = padDeleteMessage.data.padId;
if (session.padId !== padId) throw new Error('refusing cross-pad delete');
if (!await padManager.doesPadExist(padId)) return;
type ShoutMessage = {
message: string,
sticky: boolean,
}
const retrievedPad = await padManager.getPad(padId);
const firstContributor = await retrievedPad.getRevisionAuthor(0);
const isCreator = session.author === firstContributor;
const suppliedToken = padDeleteMessage.data.deletionToken;
const tokenSupplied = typeof suppliedToken === 'string' && suppliedToken !== '';
const tokenOk = tokenSupplied &&
await padDeletionManager.isValidDeletionToken(padId, suppliedToken);
// When a token is supplied it must validate. We deliberately do NOT fall
// back to the creator-cookie path, otherwise a creator pasting a wrong
// recovery token into the disclosure field would still succeed — masking a
// typo and contradicting the UI.
const creatorOk = !tokenSupplied && isCreator;
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers;
const messageToShout: ShoutMessage = {
message: 'You are not the creator of this pad, so you cannot delete it',
sticky: false
}
const messageToSend = {
type: "COLLABROOM",
data: {
type: "shoutMessage",
payload: {
message: messageToShout,
timestamp: Date.now()
}
}
}
socket.emit('shout',
messageToSend
)
}
if (creatorOk || tokenOk || flagOk) {
await retrievedPad.remove();
return;
}
}
// tokenSupplied-but-invalid is a different user-facing message from
// not-the-creator. The client localizes via the l10n key.
const messageKey = tokenSupplied
? 'pad.deletionToken.invalid'
: 'pad.deletionToken.notCreator';
socket.emit('shout', {
type: 'COLLABROOM',
data: {
type: 'shoutMessage',
payload: {
message: {
messageKey,
sticky: false,
},
timestamp: Date.now(),
},
},
});
};
const isPadCreator = async (pad: any, authorId: string) => authorId === await pad.getRevisionAuthor(0);
@ -1099,6 +1109,19 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
}
const pluginsSanitized = sanitizePluginsForWire(plugins.plugins);
// Only the original creator of the pad (revision 0 author) receives the
// deletion token, and only on their first arrival — subsequent visits get
// null because createDeletionTokenIfAbsent() only emits a plaintext token
// once. Readonly sessions never see it.
const isCreator =
!sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0);
// Skip token issuance when requireAuthentication is on: every creator has a
// stable identity so the cookie/identity path is sufficient.
const padDeletionToken = isCreator && !settings.requireAuthentication
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
: null;
// Warning: never ever send sessionInfo.padId to the client. If the client is read only you
// would open a security hole 1 swedish mile wide...
const canEditPadSettings = settings.enablePadWideSettings &&
@ -1112,6 +1135,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
},
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
padDeletionToken,
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
initialRevisionList: [],
initialOptions: pad.getPadSettings(),

View File

@ -175,6 +175,7 @@ export type SettingsType = {
updateServer: string,
enableDarkMode: boolean,
enablePadWideSettings: boolean,
allowPadDeletionByAllUsers: boolean,
skinName: string | null,
skinVariants: string,
ip: string,
@ -357,6 +358,7 @@ const settings: SettingsType = {
updateServer: "https://static.etherpad.org",
enableDarkMode: true,
enablePadWideSettings: false,
allowPadDeletionByAllUsers: false,
/*
* Skin name.
*

View File

@ -234,6 +234,45 @@ const normalizeChatOptions = (options) => {
return options;
};
// Surfaces the one-time pad deletion token when the server sends it in
// clientVars (creator session, first CLIENT_READY). The token is cleared from
// clientVars on acknowledgement so it is not re-exposed to later code paths.
const showDeletionTokenModalIfPresent = () => {
const token: string | null = (window as any).clientVars?.padDeletionToken;
if (!token) return;
const $modal = $('#deletiontoken-modal');
const $input = $('#deletiontoken-value');
const $copy = $('#deletiontoken-copy');
const $ack = $('#deletiontoken-ack');
if ($modal.length === 0) return;
$input.val(token);
const previouslyFocused = document.activeElement as HTMLElement | null;
$modal.prop('hidden', false).addClass('popup-show');
// Focus the token input so screen readers announce the dialog body and the
// user lands on the value they need to copy.
setTimeout(() => ($input[0] as HTMLInputElement)?.focus(), 0);
$copy.off('click.gdpr').on('click.gdpr', async () => {
try {
await navigator.clipboard.writeText(token);
} catch (_e) {
($input[0] as HTMLInputElement).select();
document.execCommand('copy');
}
$copy.text(html10n.get('pad.deletionToken.copied'));
});
$ack.off('click.gdpr').on('click.gdpr', () => {
$input.val('');
$modal.prop('hidden', true).removeClass('popup-show');
(window as any).clientVars.padDeletionToken = null;
if (previouslyFocused && document.body.contains(previouslyFocused)) {
previouslyFocused.focus();
}
});
};
const sendClientReady = (isReconnect) => {
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape necessary due to Safari and Opera interpretation of spaces
@ -338,14 +377,20 @@ const handshake = async () => {
socket.on('shout', (obj) => {
if(obj.type === "COLLABROOM") {
let date = new Date(obj.data.payload.timestamp);
const payload = obj.data.payload;
const msgObj = payload?.message || {};
// Pad-deletion denial shouts are surfaced inline by pad_editor.ts as an
// alert tied to the delete action; suppress the global "Admin message"
// gritter so the user doesn't see a confusing duplicate.
if (typeof msgObj.messageKey === 'string'
&& msgObj.messageKey.startsWith('pad.deletionToken.')) return;
const text = msgObj.messageKey ? html10n.get(msgObj.messageKey) : msgObj.message;
if (!text) return;
const date = new Date(payload.timestamp);
$.gritter.add({
// (string | mandatory) the heading of the notification
title: 'Admin message',
// (string | mandatory) the text inside the notification
text: '[' + date.toLocaleTimeString() + ']: ' + obj.data.payload.message.message,
// (bool | optional) if you want it to fade out on its own or just sit there
sticky: obj.data.payload.message.sticky
text: '[' + date.toLocaleTimeString() + ']: ' + text,
sticky: msgObj.sticky
});
}
})
@ -660,6 +705,8 @@ const pad = {
$('#options-darkmode').prop('checked', skinVariants.isDarkMode());
}
showDeletionTokenModalIfPresent();
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
};

View File

@ -137,6 +137,36 @@ const padeditor = (() => {
}
});
// delete pad using a recovery token (second device / no creator cookie)
$('#delete-pad-token-submit').on('click', () => {
const token = String($('#delete-pad-token-input').val() || '').trim();
if (!token) return;
if (!window.confirm(html10n.get('pad.delete.confirm'))) return;
let handled = false;
pad.socket.on('message', (data: any) => {
if (data && data.disconnect === 'deleted') {
handled = true;
window.location.href = '/';
}
});
pad.socket.on('shout', (data: any) => {
handled = true;
const payload = data?.data?.payload?.message;
const msg = payload?.messageKey
? html10n.get(payload.messageKey)
: payload?.message;
if (msg) window.alert(msg);
});
pad.collabClient.sendMessage({
type: 'PAD_DELETE',
data: {padId: pad.getPadId(), deletionToken: token},
});
setTimeout(() => {
if (!handled) window.location.href = '/';
}, 5000);
});
// delete pad
$('#delete-pad').on('click', () => {
if (window.confirm(html10n.get('pad.delete.confirm'))) {
@ -155,7 +185,10 @@ const padeditor = (() => {
// message instead of deleting. Listen for it and show the error.
pad.socket.on('shout', (data: any) => {
handled = true;
const msg = data?.data?.payload?.message?.message;
const payload = data?.data?.payload?.message;
const msg = payload?.messageKey
? html10n.get(payload.messageKey)
: payload?.message;
if (msg) window.alert(msg);
});
pad.collabClient.sendMessage({type: 'PAD_DELETE', data:{padId: pad.getPadId()}});

View File

@ -87,6 +87,8 @@ export type ClientVarPayload = {
initialTitle: string,
opts: {}
numConnectedUsers: number
canDeletePad?: boolean,
padDeletionToken?: string | null,
sofficeAvailable: string
plugins: {
plugins: MapArrayType<any>
@ -198,6 +200,7 @@ export type PadDeleteMessage = {
type: 'PAD_DELETE'
data: {
padId: string
deletionToken?: string
}
}

View File

@ -114,3 +114,65 @@
#delete-pad {
margin-top: 20px;
}
/* Pad deletion-token modal + delete-with-token disclosure (GDPR PR1) */
#deletiontoken-modal .popup-content {
max-width: 32rem;
}
#deletiontoken-modal .deletiontoken-row {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
align-items: center;
}
#deletiontoken-modal #deletiontoken-copy {
padding-top: 10px;
}
#deletiontoken-modal #deletiontoken-value {
flex: 1;
font-family: monospace;
padding: 0.4rem;
user-select: all;
}
#delete-pad-with-token {
margin-top: 0.5rem;
}
#delete-pad-with-token summary {
display: inline-block;
list-style: none;
cursor: pointer;
padding: 5px 20px;
border-radius: 4px;
font-weight: bold;
background: #d1242f;
color: #fff;
}
#delete-pad-with-token summary::-webkit-details-marker {
display: none;
}
#delete-pad-with-token summary:hover {
background: #b71c26;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12);
transform: translateY(-1px);
}
#delete-pad-with-token label {
display: block;
padding-top: 12px;
}
#delete-pad-with-token input {
margin: 0.5rem 0;
width: 100%;
font-family: monospace;
padding: 0.4rem;
}

View File

@ -254,11 +254,41 @@
<button class="btn btn-danger" data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>
</div><% } %>
</div>
<% if (!settings.requireAuthentication) { %>
<details id="delete-pad-with-token">
<summary data-l10n-id="pad.deletionToken.deleteWithToken">Delete with token</summary>
<label for="delete-pad-token-input" data-l10n-id="pad.deletionToken.tokenFieldLabel">Pad deletion token</label>
<input type="password" id="delete-pad-token-input" autocomplete="off" spellcheck="false">
<button id="delete-pad-token-submit" type="button" class="btn btn-danger"
data-l10n-id="pad.settings.deletePad">Delete pad</button>
</details>
<% } %>
<h2 data-l10n-id="pad.settings.about">About</h2>
<span data-l10n-id="pad.settings.poweredBy">Powered by</span>
<a href="https://etherpad.org" target="_blank" referrerpolicy="no-referrer" rel="noopener">Etherpad</a>
<% if (settings.exposeVersion) { %>(commit <%= settings.gitVersion %>)<% } %> </div></div>
<!--------------------------------------------------->
<!-- PAD DELETION TOKEN MODAL (shown once on create) -->
<!--------------------------------------------------->
<div id="deletiontoken-modal" class="popup" role="dialog" aria-modal="true"
aria-labelledby="deletiontoken-modal-title" aria-describedby="deletiontoken-modal-body" hidden>
<div class="popup-content">
<h1 id="deletiontoken-modal-title" data-l10n-id="pad.deletionToken.modalTitle">Save your pad deletion token</h1>
<p id="deletiontoken-modal-body" data-l10n-id="pad.deletionToken.modalBody">
This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.
</p>
<div class="deletiontoken-row">
<label for="deletiontoken-value" class="sr-only"
data-l10n-id="pad.deletionToken.tokenValueLabel">Your pad deletion token (read-only)</label>
<input type="text" id="deletiontoken-value" readonly>
<button id="deletiontoken-copy" type="button" aria-live="polite"
data-l10n-id="pad.deletionToken.copy">Copy</button>
</div>
<button id="deletiontoken-ack" type="button" class="btn btn-primary"
data-l10n-id="pad.deletionToken.acknowledge">I've saved it</button>
</div></div>
<!------------------------->
<!-- IMPORT EXPORT POPUP -->

View File

@ -0,0 +1,89 @@
'use strict';
import {strict as assert} from 'assert';
const common = require('../../common');
import settings from '../../../../node/utils/Settings';
let agent: any;
let apiVersion = 1;
const endPoint = (p: string) => `/api/${apiVersion}/${p}`;
const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const callApi = async (point: string, query: Record<string, string> = {}) => {
const qs = new URLSearchParams(query).toString();
const path = qs ? `${endPoint(point)}?${qs}` : endPoint(point);
return await agent.get(path)
.set('authorization', await common.generateJWTToken())
.expect(200)
.expect('Content-Type', /json/);
};
describe(__filename, function () {
before(async function () {
this.timeout(60000);
agent = await common.init();
const res = await agent.get('/api/').expect(200);
apiVersion = res.body.currentVersion;
});
afterEach(function () {
settings.allowPadDeletionByAllUsers = false;
settings.requireAuthentication = false;
});
it('createPad returns a plaintext deletionToken the first time', async function () {
const padId = makeId();
const res = await callApi('createPad', {padID: padId});
assert.equal(res.body.code, 0, JSON.stringify(res.body));
assert.equal(typeof res.body.data.deletionToken, 'string');
assert.ok(res.body.data.deletionToken.length >= 32);
await callApi('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken});
});
it('deletePad with a valid deletionToken succeeds', async function () {
const padId = makeId();
const create = await callApi('createPad', {padID: padId});
const token = create.body.data.deletionToken;
const del = await callApi('deletePad', {padID: padId, deletionToken: token});
assert.equal(del.body.code, 0, JSON.stringify(del.body));
const check = await callApi('getText', {padID: padId});
assert.equal(check.body.code, 1); // "padID does not exist"
});
it('deletePad with a wrong deletionToken is refused', async function () {
const padId = makeId();
await callApi('createPad', {padID: padId});
const del = await callApi('deletePad', {padID: padId, deletionToken: 'not-the-real-token'});
assert.equal(del.body.code, 1);
assert.match(del.body.message, /invalid deletionToken/);
// cleanup — JWT-authenticated caller is trusted when no token is supplied
await callApi('deletePad', {padID: padId});
});
it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () {
const padId = makeId();
await callApi('createPad', {padID: padId});
settings.allowPadDeletionByAllUsers = true;
const del = await callApi('deletePad', {padID: padId, deletionToken: 'bogus'});
assert.equal(del.body.code, 0);
});
it('createPad returns null deletionToken when requireAuthentication is on', async function () {
settings.requireAuthentication = true;
const padId = makeId();
const res = await callApi('createPad', {padID: padId});
assert.equal(res.body.code, 0, JSON.stringify(res.body));
assert.equal(res.body.data.deletionToken, null);
await callApi('deletePad', {padID: padId});
});
it('JWT admin call (no deletionToken) still works — admins stay trusted', async function () {
const padId = makeId();
await callApi('createPad', {padID: padId});
const del = await callApi('deletePad', {padID: padId});
assert.equal(del.body.code, 0);
});
});

View File

@ -0,0 +1,104 @@
'use strict';
import {strict as assert} from 'assert';
const common = require('../common');
const padDeletionManager = require('../../../node/db/PadDeletionManager');
describe(__filename, function () {
before(async function () { await common.init(); });
const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
describe('createDeletionTokenIfAbsent', function () {
it('returns a non-empty string on first call', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(typeof token, 'string');
assert.ok(token.length >= 32);
await padDeletionManager.removeDeletionToken(padId);
});
it('returns null on subsequent calls for the same pad', async function () {
const padId = uniqueId();
const first = await padDeletionManager.createDeletionTokenIfAbsent(padId);
const second = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(typeof first, 'string');
assert.equal(second, null);
await padDeletionManager.removeDeletionToken(padId);
});
it('emits different tokens for different pads', async function () {
const a = uniqueId();
const b = uniqueId();
const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a);
const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b);
assert.notEqual(tokenA, tokenB);
await padDeletionManager.removeDeletionToken(a);
await padDeletionManager.removeDeletionToken(b);
});
it('concurrent calls for the same pad produce a single validating token',
async function () {
const padId = uniqueId();
const results = await Promise.all(
Array.from({length: 8},
() => padDeletionManager.createDeletionTokenIfAbsent(padId)));
// Exactly one caller should get the plaintext token; the rest see null.
const nonNull = results.filter((r) => r != null);
assert.equal(nonNull.length, 1, `results: ${JSON.stringify(results)}`);
const [token] = nonNull;
assert.equal(
await padDeletionManager.isValidDeletionToken(padId, token), true,
'the one token returned must validate against the stored hash');
await padDeletionManager.removeDeletionToken(padId);
});
});
describe('isValidDeletionToken', function () {
it('accepts the token returned by the matching pad', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true);
await padDeletionManager.removeDeletionToken(padId);
});
it('rejects a token for the wrong pad', async function () {
const a = uniqueId();
const b = uniqueId();
const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a);
await padDeletionManager.createDeletionTokenIfAbsent(b);
assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false);
await padDeletionManager.removeDeletionToken(a);
await padDeletionManager.removeDeletionToken(b);
});
it('rejects a non-string token', async function () {
const padId = uniqueId();
await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false);
await padDeletionManager.removeDeletionToken(padId);
});
it('returns false for pads that never had a token', async function () {
const padId = uniqueId();
assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false);
});
});
describe('removeDeletionToken', function () {
it('invalidates the stored token', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
await padDeletionManager.removeDeletionToken(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false);
});
it('is safe to call when no token exists', async function () {
const padId = uniqueId();
await padDeletionManager.removeDeletionToken(padId); // must not throw
});
});
});

View File

@ -137,6 +137,21 @@ export const goToNewPad = async (page: Page) => {
const padId = "FRONTEND_TESTS"+randomUUID();
await page.goto('http://localhost:9001/p/'+padId);
await waitForEditorReady(page);
// Creator sessions see the one-time pad-deletion-token modal on first visit.
// Hide it directly instead of clicking the ack button — clicking the button
// transfers focus out of the pad iframe and breaks subsequent keyboard tests.
// Tests that need to interact with the modal should navigate to a new pad
// inline instead of using this helper.
await page.evaluate(() => {
const modal = document.getElementById('deletiontoken-modal');
if (modal == null || modal.hidden) return;
modal.hidden = true;
modal.classList.remove('popup-show');
const input = document.getElementById('deletiontoken-value') as HTMLInputElement | null;
if (input) input.value = '';
const w = window as unknown as {clientVars?: {padDeletionToken?: string | null}};
if (w.clientVars != null) w.clientVars.padDeletionToken = null;
});
return padId;
}

View File

@ -0,0 +1,116 @@
import {expect, test, Page} from '@playwright/test';
import {randomUUID} from 'node:crypto';
import {goToPad} from '../helper/padHelper';
import {showSettings} from '../helper/settingsHelper';
// goToNewPad() in the shared helper auto-dismisses the deletion-token modal
// so unrelated tests aren't blocked. These tests need the modal, so they
// navigate inline without the helper.
const newPadKeepingModal = async (page: Page) => {
const padId = `FRONTEND_TESTS${randomUUID()}`;
await page.goto(`http://localhost:9001/p/${padId}`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');
return padId;
};
test.describe('pad deletion token', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('creator sees a token modal exactly once and can dismiss it', async ({page}) => {
await newPadKeepingModal(page);
const modal = page.locator('#deletiontoken-modal');
await expect(modal).toBeVisible();
const tokenValue = await page.locator('#deletiontoken-value').inputValue();
expect(tokenValue.length).toBeGreaterThanOrEqual(32);
await page.locator('#deletiontoken-ack').click();
await expect(modal).toBeHidden();
const cleared = await page.evaluate(
() => (window as any).clientVars.padDeletionToken);
expect(cleared == null).toBe(true);
});
test('second device can delete using the captured token', async ({page, browser}) => {
const padId = await newPadKeepingModal(page);
const token = await page.locator('#deletiontoken-value').inputValue();
await page.locator('#deletiontoken-ack').click();
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await goToPad(page2, padId);
await showSettings(page2);
await page2.locator('#delete-pad-with-token > summary').click();
await page2.locator('#delete-pad-token-input').fill(token);
page2.once('dialog', (d) => d.accept());
await page2.locator('#delete-pad-token-submit').click();
await page2.waitForURL((url) => url.pathname === '/' || url.pathname.endsWith('/index.html'),
{timeout: 10000});
await context2.close();
});
test('creator pasting a wrong token into the disclosure field does not delete the pad',
async ({page}) => {
const padId = await newPadKeepingModal(page);
await page.locator('#deletiontoken-ack').click();
// Same browser context — the creator cookie/identity is still in place.
// The bug we're guarding against: handler short-circuited on isCreator
// and ignored a supplied-but-invalid token, so the pad was deleted anyway.
await showSettings(page);
await page.locator('#delete-pad-with-token > summary').click();
await page.locator('#delete-pad-token-input').fill('definitely-not-the-real-token');
const dialogs: string[] = [];
page.on('dialog', async (d) => {
dialogs.push(d.message());
await d.accept();
});
await page.locator('#delete-pad-token-submit').click();
await expect.poll(() => dialogs.length, {timeout: 10000}).toBeGreaterThanOrEqual(2);
expect(dialogs.some((m) => /not valid for this pad/i.test(m))).toBe(true);
// Regression guard: the global shout handler should NOT surface a
// "Admin message" gritter for deletion-denial shouts, and certainly never
// an "undefined" body.
const gritterHits = await page.locator('.gritter-item').allInnerTexts();
expect(gritterHits.join('\n')).not.toMatch(/Admin message/);
expect(gritterHits.join('\n')).not.toMatch(/undefined/);
// Pad must still exist — reload and verify the editor comes back.
await page.goto(`http://localhost:9001/p/${padId}`);
await expect(page.locator('#editorcontainer.initialized')).toBeVisible();
});
test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => {
const padId = await newPadKeepingModal(page);
await page.locator('#deletiontoken-ack').click();
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await goToPad(page2, padId);
await showSettings(page2);
await page2.locator('#delete-pad-with-token > summary').click();
await page2.locator('#delete-pad-token-input').fill('bogus-token-value');
const dialogs: string[] = [];
page2.on('dialog', async (d) => {
dialogs.push(d.message());
await d.accept();
});
await page2.locator('#delete-pad-token-submit').click();
await expect.poll(() => dialogs.length, {timeout: 10000}).toBeGreaterThanOrEqual(2);
expect(dialogs.some((m) => /not valid for this pad/i.test(m))).toBe(true);
await page.reload();
await expect(page.locator('#editorcontainer.initialized')).toBeVisible();
await context2.close();
});
});