From 74daebef08bb494ec292844ce928ffbfc3b3e686 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:45:06 +0100 Subject: [PATCH 01/15] docs: PR4 GDPR privacy banner design spec --- ...26-04-19-gdpr-pr4-privacy-banner-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md diff --git a/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md b/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md new file mode 100644 index 00000000000..fd54f508ea2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md @@ -0,0 +1,183 @@ +# PR4 — GDPR Configurable Privacy Banner + +Fourth of five GDPR PRs (ether/etherpad#6701). Lets instance operators +surface a short, localisable privacy notice — data processing statement, +retention policy, contact for erasure requests — when a user opens or +creates a pad, without writing a plugin. + +## Goals + +- One `settings.json` block defines the banner: whether it's shown, the + title, the body, a "learn more" link, and how dismissal works. +- Banner renders on every pad load when enabled. The user can dismiss + it once per browser (stored in `localStorage`) if the operator + chose "dismissible". +- Works with the `colibris` skin out of the box, no plugin required. +- Disabled by default — instances that don't want a banner see no + behaviour change. + +## Non-goals + +- Markdown rendering. Body is plain text; HTML escaped at render. +- Consent recording / "I consent" persistence. This is informational + only — recording consent is a separate compliance regime. +- Multi-language. Operators who need l10n can wrap the body in their + own plugin-level substitution. +- Admin UI for editing the banner. Edits happen in `settings.json`. + +## Design + +### Settings + +```jsonc +"privacyBanner": { + /* + * Master switch. Defaults to false so existing instances are unchanged. + */ + "enabled": false, + /* + * Short heading shown in bold. Plain text, HTML is escaped. + */ + "title": "Privacy notice", + /* + * Body text. Plain text, HTML is escaped. Newlines become
. + */ + "body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.", + /* + * Optional URL appended as a "Learn more" link. Omit or set to null + * to hide the link. + */ + "learnMoreUrl": null, + /* + * One of: + * "dismissible" (default) — show a close button; dismissal persists + * in localStorage under a per-instance key + * "sticky" — no close button; banner shown every load + */ + "dismissal": "dismissible" +} +``` + +`SettingsType` gains a matching strongly-typed block. The default in +code is `{enabled: false, title: '', body: '', learnMoreUrl: null, +dismissal: 'dismissible'}`. + +### Server wiring + +- `settings.getPublicSettings()` picks up a trimmed view of the banner: + `{enabled, title, body, learnMoreUrl, dismissal}`. Nothing else from + `privacyBanner` leaks. +- `PadMessageHandler` already sends `settings.getPublicSettings()` via + `clientVars.skinName` etc. — add the banner shape to `ClientVarPayload` + and include it in the clientVars literal. + +### Template + +- Add ` +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/templates/pad.html +git commit -m "feat(gdpr): privacy banner DOM (hidden by default)" +``` + +--- + +## Task 4: `privacy_banner.ts` + wire into `pad.ts` + +**Files:** +- Create: `src/static/js/privacy_banner.ts` +- Modify: `src/static/js/pad.ts` — call after `postAceInit` + +- [ ] **Step 1: Create the module** + +```typescript +// src/static/js/privacy_banner.ts +'use strict'; + +type BannerConfig = { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', +}; + +const storageKey = (url: string): string => { + try { + return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`; + } catch (_e) { + return 'etherpad.privacyBanner.dismissed'; + } +}; + +export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => { + if (!config || !config.enabled) return; + const banner = document.getElementById('privacy-banner'); + if (banner == null) return; + + if (config.dismissal === 'dismissible') { + try { + if (localStorage.getItem(storageKey(location.href)) === '1') return; + } catch (_e) { /* proceed without persistence */ } + } + + const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null; + if (titleEl) titleEl.textContent = config.title || ''; + + const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null; + if (bodyEl) { + bodyEl.textContent = ''; + for (const line of (config.body || '').split(/\r?\n/)) { + const p = document.createElement('p'); + p.textContent = line; + bodyEl.appendChild(p); + } + } + + const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null; + if (linkEl) { + linkEl.replaceChildren(); + if (config.learnMoreUrl) { + const a = document.createElement('a'); + a.href = config.learnMoreUrl; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Learn more'; + linkEl.appendChild(a); + } + } + + const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null; + if (closeBtn) { + if (config.dismissal === 'dismissible') { + closeBtn.hidden = false; + closeBtn.onclick = () => { + banner.hidden = true; + try { + localStorage.setItem(storageKey(location.href), '1'); + } catch (_e) { /* best-effort */ } + }; + } else { + closeBtn.hidden = true; + } + } + + banner.hidden = false; +}; +``` + +- [ ] **Step 2: Call it from `pad.ts`** + +In `src/static/js/pad.ts`, inside `postAceInit` (just after the +existing `showDeletionTokenModalIfPresent()` / modal call on the +post-PR1 branch, or just before `hooks.aCallAll('postAceInit', …)`), +add an import at the top: + +```typescript +import {showPrivacyBannerIfEnabled} from './privacy_banner'; +``` + +And a call inside `postAceInit`: + +```typescript + showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); +``` + +- [ ] **Step 3: Type check + commit** + +```bash +pnpm --filter ep_etherpad-lite run ts-check +git add src/static/js/privacy_banner.ts src/static/js/pad.ts +git commit -m "feat(gdpr): render privacy banner on pad load when enabled" +``` + +--- + +## Task 5: Skin styling + +**Files:** +- Modify: `src/static/skins/colibris/src/components/popup.css` (or an adjacent components file) + +- [ ] **Step 1: Append minimal styling** + +```css +.privacy-banner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin: 0.5rem 1rem; + padding: 0.75rem 1rem; + background-color: #fff7d6; + border: 1px solid #e0c97a; + border-radius: 4px; + color: #333; + font-size: 0.9rem; +} + +.privacy-banner .privacy-banner-content { + flex: 1; +} + +.privacy-banner .privacy-banner-title { + display: block; + margin-bottom: 0.25rem; +} + +.privacy-banner .privacy-banner-body p { + margin: 0.2rem 0; +} + +.privacy-banner .privacy-banner-link a { + text-decoration: underline; +} + +.privacy-banner .privacy-banner-close { + background: transparent; + border: 0; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: inherit; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/static/skins/colibris/src/components/popup.css +git commit -m "style(gdpr): privacy banner layout" +``` + +--- + +## Task 6: Playwright coverage + +**Files:** +- Create: `src/tests/frontend-new/specs/privacy_banner.spec.ts` + +- [ ] **Step 1: Write the spec** + +```typescript +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +const freshPad = 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; +}; + +// The server's `settings.privacyBanner` is swapped at runtime via page.evaluate +// on the clientVars object + manual reveal so the test is fully self-contained. +// Operators setting the live setting is covered by the settings unit test. +const forceBanner = async (page: Page, config: any) => { + await page.evaluate((cfg) => { + (window as any).clientVars.privacyBanner = cfg; + const mod = require('../../../src/static/js/privacy_banner'); + mod.showPrivacyBannerIfEnabled(cfg); + }, config); +}; + +test.describe('privacy banner', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('disabled by default — banner stays hidden', async ({page}) => { + await freshPad(page); + await expect(page.locator('#privacy-banner')).toBeHidden(); + }); + + test('enabled + sticky — banner visible, close button hidden', + async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; + banner.hidden = false; + }); + await expect(page.locator('#privacy-banner')).toBeVisible(); + await expect(page.locator('#privacy-banner-close')).toBeHidden(); + }); + + test('dismissible — close button hides and persists in localStorage', + async ({page}) => { + const padId = await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; + close.hidden = false; + close.onclick = () => { + banner.hidden = true; + localStorage.setItem( + `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); + }; + banner.hidden = false; + }); + await page.locator('#privacy-banner-close').click(); + await expect(page.locator('#privacy-banner')).toBeHidden(); + + const flag = await page.evaluate( + () => localStorage.getItem( + `etherpad.privacyBanner.dismissed:${location.origin}`)); + expect(flag).toBe('1'); + }); +}); +``` + +- [ ] **Step 2: Restart the test server and run** + +```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 +cd src && NODE_ENV=production npx playwright test privacy_banner --project=chromium +``` + +Expected: 3 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/frontend-new/specs/privacy_banner.spec.ts +git commit -m "test(gdpr): Playwright coverage for privacy banner" +``` + +--- + +## Task 7: Docs + +**Files:** +- Modify: `doc/privacy.md` (created in PR2 #7547 — may not be on this branch yet. If missing, create a minimal stub.) + +- [ ] **Step 1: Check if `doc/privacy.md` exists; if not, create a stub** + +Run: `ls doc/privacy.md` + +If missing, create a minimal file so the banner doc has a home: + +```markdown +# Privacy + +See [cookies.md](cookies.md) for the cookie list and the GDPR work +tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701). + +## Privacy banner (optional) + +(content added by this PR — see next step) +``` + +- [ ] **Step 2: Append the banner section** + +Append: + +```markdown +## Privacy banner (optional) + +The `privacyBanner` block in `settings.json` lets you display a short +notice to every pad user — data-processing statement, retention policy, +contact for erasure requests, etc. + +```jsonc +"privacyBanner": { + "enabled": true, + "title": "Privacy notice", + "body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.", + "learnMoreUrl": "https://example.com/privacy", + "dismissal": "dismissible" +} +``` + +The banner is rendered from plain text (HTML is escaped) with one +paragraph per line. With `dismissal: "dismissible"` the user can close +the banner and the choice is remembered in `localStorage` per origin. +`dismissal: "sticky"` removes the close button. +``` + +- [ ] **Step 3: Commit** + +```bash +git add doc/privacy.md +git commit -m "docs(gdpr): privacyBanner configuration section" +``` + +--- + +## Task 8: Verify, push, open PR + +- [ ] **Step 1: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 2: Run Playwright for the banner + a chat regression** + +```bash +cd src && NODE_ENV=production npx playwright test privacy_banner chat.spec --project=chromium +``` + +Expected: all tests pass. + +- [ ] **Step 3: Push + open PR** + +```bash +git push origin feat-gdpr-privacy-banner +gh pr create --repo ether/etherpad --base develop --head feat-gdpr-privacy-banner \ + --title "feat(gdpr): configurable privacy banner (PR4 of #6701)" --body "$(cat <<'EOF' +## Summary +- New `privacyBanner` block in `settings.json` (title/body/learnMoreUrl/dismissal); defaults to disabled so existing instances are unchanged. +- Banner renders via `clientVars.privacyBanner` after pad init; content is set via `textContent` (HTML escaped). +- `dismissible` stores a per-origin flag in `localStorage` so the user only sees it once; `sticky` shows it every load. + +Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already open/merged. PR5 (author erasure) is the last. + +Design: `docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md` +Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md` + +## Test plan +- [x] ts-check +- [x] Playwright — disabled / sticky / dismissible +EOF +)" +``` + +- [ ] **Step 4: Monitor CI** + +Run: `gh pr checks --repo ether/etherpad` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +| --- | --- | +| `privacyBanner` settings block | 1 | +| `getPublicSettings()` exposure | 1 | +| `clientVars.privacyBanner` wiring | 2 | +| Template DOM | 3 | +| Client JS (textContent, link, close button) | 4 | +| Styling | 5 | +| Playwright tests | 6 | +| Docs | 7 | + +**Placeholders:** none. + +**Type consistency:** +- `BannerConfig` shape matches `SettingsType.privacyBanner` (Task 1) exactly (Task 4). +- `dismissal: 'dismissible' | 'sticky'` union consistent in Tasks 1, 2, 4. +- `clientVars.privacyBanner` optional on the client, always sent from the server — matches `?:` on `ClientVarPayload`. From b78f27be385a5578bb28d25977027bd1e6caa621 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:49:10 +0100 Subject: [PATCH 03/15] feat(gdpr): typed privacyBanner setting block + public getter exposure --- settings.json.docker | 11 +++++++++++ settings.json.template | 18 ++++++++++++++++++ src/node/utils/Settings.ts | 18 +++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/settings.json.docker b/settings.json.docker index bbd6413577f..976a5eb7e45 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -241,6 +241,17 @@ **/ "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + /* + * Optional privacy banner. See settings.json.template for full field docs. + */ + "privacyBanner": { + "enabled": "${PRIVACY_BANNER_ENABLED:false}", + "title": "${PRIVACY_BANNER_TITLE:Privacy notice}", + "body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}", + "learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}", + "dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}" + }, + /* * Node native SSL support * diff --git a/settings.json.template b/settings.json.template index d5c7eb44b2b..5e8e429d7f1 100644 --- a/settings.json.template +++ b/settings.json.template @@ -725,6 +725,24 @@ **/ "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + /* + * Optional privacy banner shown once the pad loads. Disabled by default. + * + * enabled — toggle the feature + * title — plain-text heading (HTML is escaped) + * body — plain-text body; newlines become paragraph breaks + * learnMoreUrl — optional URL rendered as a "Learn more" link + * dismissal — "dismissible" (close button, stored in localStorage) + * or "sticky" (always shown, no close button) + */ + "privacyBanner": { + "enabled": false, + "title": "Privacy notice", + "body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.", + "learnMoreUrl": null, + "dismissal": "dismissible" + }, + /* * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited * diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 7cd42a81185..f14c969d135 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -176,6 +176,13 @@ export type SettingsType = { enableDarkMode: boolean, enablePadWideSettings: boolean, allowPadDeletionByAllUsers: boolean, + privacyBanner: { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', + }, skinName: string | null, skinVariants: string, ip: string, @@ -313,7 +320,7 @@ export type SettingsType = { requireAdminForStatus: boolean, }, adminEmail: string | null, - getPublicSettings: () => Pick, + getPublicSettings: () => Pick, } const settings: SettingsType = { @@ -361,6 +368,14 @@ const settings: SettingsType = { enableDarkMode: true, enablePadWideSettings: false, allowPadDeletionByAllUsers: false, + privacyBanner: { + enabled: false, + title: 'Privacy notice', + body: 'This instance processes pad content on our servers. ' + + 'See the linked policy for retention and how to request erasure.', + learnMoreUrl: null, + dismissal: 'dismissible', + }, /* * Skin name. * @@ -718,6 +733,7 @@ const settings: SettingsType = { skinName: settings.skinName, skinVariants: settings.skinVariants, enablePadWideSettings: settings.enablePadWideSettings, + privacyBanner: settings.privacyBanner, } }, gitVersion: getGitCommit(), From c85bdf0b50ab4a84b11d2add245a37c924869990 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:49:48 +0100 Subject: [PATCH 04/15] feat(gdpr): send privacyBanner config to the browser via clientVars --- src/node/handler/PadMessageHandler.ts | 1 + src/static/js/types/SocketIOMessage.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 409055e606e..09b2480311f 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -1157,6 +1157,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { enableDarkMode: settings.enableDarkMode, enablePadWideSettings: settings.enablePadWideSettings, padDeletionToken, + privacyBanner: settings.privacyBanner, automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], initialOptions: pad.getPadSettings(), diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index 22aded20cdd..f5e103d2994 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -62,6 +62,13 @@ export type ClientVarPayload = { userColor: number, hideChat?: boolean, padOptions: PadOption, + privacyBanner?: { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', + }, padId: string, colorPalette: string[], accountPrivs: { From ba1e013460b40c7f6adb3829827ca3650766412a Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:50:20 +0100 Subject: [PATCH 05/15] feat(gdpr): privacy banner DOM (hidden by default) --- src/templates/pad.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/templates/pad.html b/src/templates/pad.html index ecac35ad607..5fb0dc3a485 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -88,6 +88,16 @@ <% e.begin_block("afterEditbar"); %><% e.end_block(); %> + +
<% e.begin_block("editorContainerBox"); %> From 26bc4c1f6216d88874e1557a728cfc1cf8aeac6e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:51:34 +0100 Subject: [PATCH 06/15] feat(gdpr): render privacy banner on pad load when enabled --- src/static/js/pad.ts | 2 + src/static/js/privacy_banner.ts | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/static/js/privacy_banner.ts diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 8537805bbc7..72364c26149 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -53,6 +53,7 @@ import {randomString} from "./pad_utils"; const socketio = require('./socketio'); const hooks = require('./pluginfw/hooks'); +import {showPrivacyBannerIfEnabled} from './privacy_banner'; import './pad_version_badge'; @@ -717,6 +718,7 @@ const pad = { } showDeletionTokenModalIfPresent(); + showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; diff --git a/src/static/js/privacy_banner.ts b/src/static/js/privacy_banner.ts new file mode 100644 index 00000000000..b16d4512ede --- /dev/null +++ b/src/static/js/privacy_banner.ts @@ -0,0 +1,72 @@ +'use strict'; + +type BannerConfig = { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', +}; + +const storageKey = (url: string): string => { + try { + return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`; + } catch (_e) { + return 'etherpad.privacyBanner.dismissed'; + } +}; + +export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => { + if (!config || !config.enabled) return; + const banner = document.getElementById('privacy-banner'); + if (banner == null) return; + + if (config.dismissal === 'dismissible') { + try { + if (localStorage.getItem(storageKey(location.href)) === '1') return; + } catch (_e) { /* proceed without persistence */ } + } + + const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null; + if (titleEl) titleEl.textContent = config.title || ''; + + const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null; + if (bodyEl) { + bodyEl.textContent = ''; + for (const line of (config.body || '').split(/\r?\n/)) { + const p = document.createElement('p'); + p.textContent = line; + bodyEl.appendChild(p); + } + } + + const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null; + if (linkEl) { + linkEl.replaceChildren(); + if (config.learnMoreUrl) { + const a = document.createElement('a'); + a.href = config.learnMoreUrl; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Learn more'; + linkEl.appendChild(a); + } + } + + const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null; + if (closeBtn) { + if (config.dismissal === 'dismissible') { + closeBtn.hidden = false; + closeBtn.onclick = () => { + banner.hidden = true; + try { + localStorage.setItem(storageKey(location.href), '1'); + } catch (_e) { /* best-effort */ } + }; + } else { + closeBtn.hidden = true; + } + } + + banner.hidden = false; +}; From 4d85e3609bd85054f323a2c2a771a7aa9dfdbf65 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:51:52 +0100 Subject: [PATCH 07/15] style(gdpr): privacy banner layout --- .../skins/colibris/src/components/popup.css | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 9d61d51764b..18a2e674f7b 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -176,3 +176,42 @@ padding: 0.4rem; } +/* GDPR privacy banner (PR4) */ +.privacy-banner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin: 0.5rem 1rem; + padding: 0.75rem 1rem; + background-color: #fff7d6; + border: 1px solid #e0c97a; + border-radius: 4px; + color: #333; + font-size: 0.9rem; +} + +.privacy-banner .privacy-banner-content { + flex: 1; +} + +.privacy-banner .privacy-banner-title { + display: block; + margin-bottom: 0.25rem; +} + +.privacy-banner .privacy-banner-body p { + margin: 0.2rem 0; +} + +.privacy-banner .privacy-banner-link a { + text-decoration: underline; +} + +.privacy-banner .privacy-banner-close { + background: transparent; + border: 0; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: inherit; +} From 0c20e401d5afb3558add90818d8dea66d79768cc Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:54:11 +0100 Subject: [PATCH 08/15] test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override --- .../skins/colibris/src/components/popup.css | 4 ++ .../frontend-new/specs/privacy_banner.spec.ts | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/tests/frontend-new/specs/privacy_banner.spec.ts diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 18a2e674f7b..a0d6f2a636d 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -177,6 +177,10 @@ } /* GDPR privacy banner (PR4) */ +.privacy-banner[hidden] { + display: none !important; +} + .privacy-banner { display: flex; align-items: flex-start; diff --git a/src/tests/frontend-new/specs/privacy_banner.spec.ts b/src/tests/frontend-new/specs/privacy_banner.spec.ts new file mode 100644 index 00000000000..d9b6b6ac243 --- /dev/null +++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts @@ -0,0 +1,67 @@ +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +const freshPad = 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('privacy banner', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('disabled by default — banner stays hidden', async ({page}) => { + await freshPad(page); + await expect(page.locator('#privacy-banner')).toBeHidden(); + }); + + test('sticky banner is visible and has no close button', async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; + banner.hidden = false; + }); + await expect(page.locator('#privacy-banner')).toBeVisible(); + await expect(page.locator('#privacy-banner-close')).toBeHidden(); + }); + + test('dismissible — close button hides and persists in localStorage', + async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; + close.hidden = false; + close.onclick = () => { + banner.hidden = true; + localStorage.setItem( + `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); + }; + banner.hidden = false; + }); + await page.locator('#privacy-banner-close').click(); + await expect(page.locator('#privacy-banner')).toBeHidden(); + + const flag = await page.evaluate( + () => localStorage.getItem( + `etherpad.privacyBanner.dismissed:${location.origin}`)); + expect(flag).toBe('1'); + }); +}); From 55f8c7e804e4bb60e18998b2dd5d721bfa1d9d0f Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:54:42 +0100 Subject: [PATCH 09/15] docs(gdpr): privacyBanner configuration section --- doc/privacy.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/doc/privacy.md b/doc/privacy.md index 4659ae75648..c7fe8a6ac02 100644 --- a/doc/privacy.md +++ b/doc/privacy.md @@ -59,3 +59,25 @@ See [`docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md`](https://github.com/ether/etherpad/blob/develop/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md) for the deletion-token mechanism. Full author erasure is tracked as a follow-up in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701). + +## Privacy banner (optional) + +The `privacyBanner` block in `settings.json` lets you display a short +notice to every pad user — data-processing statement, retention +policy, contact for erasure requests, etc. + +```jsonc +"privacyBanner": { + "enabled": true, + "title": "Privacy notice", + "body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.", + "learnMoreUrl": "https://example.com/privacy", + "dismissal": "dismissible" +} +``` + +The banner is rendered from plain text (HTML is escaped) with one +paragraph per line. With `dismissal: "dismissible"` the user can close +the banner and the choice is remembered in `localStorage` per origin. +`dismissal: "sticky"` removes the close button so the notice is shown +on every pad load. From 933d8d21bf0efe7156de32f8f111625de0008ed6 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 11:25:59 +0100 Subject: [PATCH 10/15] fix(gdpr): reject unsafe learnMoreUrl schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl directly to , so a misconfigured settings.privacyBanner. learnMoreUrl of `javascript:alert(1)` or `data:…'), + https: run('https://example.com/privacy'), + mailto: run('mailto:privacy@example.com'), + }; + }); + expect(results.javascript).toBeNull(); + expect(results.dataUrl).toBeNull(); + expect(results.https).toBe('https://example.com/privacy'); + expect(results.mailto).toBe('mailto:privacy@example.com'); + }); }); From 8891d105cef5a5370405f6fcd193c73d41e98474 Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 1 May 2026 07:59:52 +0100 Subject: [PATCH 11/15] fix(privacy-banner): drop unneeded !important on [hidden] rule Class+attribute selector already outranks `.privacy-banner { display: flex }` on specificity (0,2,0 vs 0,1,0), so `!important` was redundant. Adds a comment explaining why so a future reader doesn't put it back. Per Sam's review on #7549. --- src/static/skins/colibris/src/components/popup.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index a0d6f2a636d..1b105c6614d 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -177,8 +177,11 @@ } /* GDPR privacy banner (PR4) */ +/* `.privacy-banner[hidden]` (class+attr, specificity 0,2,0) outranks the + * `.privacy-banner { display: flex }` rule below (0,1,0), so the UA `[hidden]` + * default isn't clobbered and we don't need `!important`. */ .privacy-banner[hidden] { - display: none !important; + display: none; } .privacy-banner { From 906e145aaad3d6b502d40e1588ac492b0c58219c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 06:18:00 +0100 Subject: [PATCH 12/15] refactor(privacy-banner): render as a persistent gritter, not custom DOM Drops the bespoke #privacy-banner template + ~50 lines of popup.css and delegates to $.gritter.add({sticky: true, position: 'bottom'}). The notice now matches every other gritter on the pad (theme variables, shadow, animation, (X) close), sits in the bottom corner instead of above the editor, and inherits dark-mode handling for free. The two dismissal modes survive intact: - dismissible: gritter closes on (X); before_close persists a flag in localStorage so the notice is suppressed on subsequent loads. - sticky: closes for the current session only; never persists; the next pad load shows it again. learnMoreUrl still goes through the same safeUrl() filter so a javascript:/data:/vbscript: URL can't smuggle a script handler into the anchor (Qodo's review concern remains addressed). Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__ test hook and asserts against the rendered gritter, instead of the previous tests that mutated DOM by hand and never exercised the function under test. Coverage adds: enabled=false short-circuit, dismissible-flag-respected on subsequent show, sticky-ignores-flag, sticky-close-does-not-persist, javascript: rejection, data: rejection, and mailto: allow-list. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/static/js/privacy_banner.ts | 95 +++---- .../skins/colibris/src/components/popup.css | 47 ---- src/templates/pad.html | 10 - .../frontend-new/specs/privacy_banner.spec.ts | 237 ++++++++++++------ 4 files changed, 213 insertions(+), 176 deletions(-) diff --git a/src/static/js/privacy_banner.ts b/src/static/js/privacy_banner.ts index e15e6efdb08..d07701e3f65 100644 --- a/src/static/js/privacy_banner.ts +++ b/src/static/js/privacy_banner.ts @@ -22,8 +22,6 @@ const storageKey = (url: string): string => { const SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:']); const safeUrl = (href: string | null | undefined): string | null => { if (typeof href !== 'string' || href === '') return null; - // Reject protocol-relative and scheme-less values that the browser might - // resolve to something unexpected. Require an explicit scheme. let parsed: URL; try { parsed = new URL(href, location.href); @@ -34,10 +32,32 @@ const safeUrl = (href: string | null | undefined): string | null => { return parsed.href; }; +// Build a jQuery DOM fragment for the gritter `text` parameter. Each line of +// the body becomes its own

(mirrors what the original config supports), and +// an optional "Learn more" anchor is appended only after the URL has passed +// through safeUrl(). +const buildBody = (config: BannerConfig): JQuery => { + const $ = (window as any).$; + const wrap = $('

'); + for (const line of (config.body || '').split(/\r?\n/)) { + wrap.append($('

').text(line)); + } + const safeHref = safeUrl(config.learnMoreUrl); + if (safeHref != null) { + wrap.append($('

').append( + $('') + .attr('href', safeHref) + .attr('target', '_blank') + .attr('rel', 'noopener') + .text('Learn more'))); + } + return wrap; +}; + export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => { if (!config || !config.enabled) return; - const banner = document.getElementById('privacy-banner'); - if (banner == null) return; + const $ = (window as any).$; + if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return; if (config.dismissal === 'dismissible') { try { @@ -45,47 +65,28 @@ export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => } catch (_e) { /* proceed without persistence */ } } - const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null; - if (titleEl) titleEl.textContent = config.title || ''; - - const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null; - if (bodyEl) { - bodyEl.textContent = ''; - for (const line of (config.body || '').split(/\r?\n/)) { - const p = document.createElement('p'); - p.textContent = line; - bodyEl.appendChild(p); - } - } - - const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null; - if (linkEl) { - linkEl.replaceChildren(); - const safeHref = safeUrl(config.learnMoreUrl); - if (safeHref != null) { - const a = document.createElement('a'); - a.href = safeHref; - a.target = '_blank'; - a.rel = 'noopener'; - a.textContent = 'Learn more'; - linkEl.appendChild(a); - } - } - - const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null; - if (closeBtn) { - if (config.dismissal === 'dismissible') { - closeBtn.hidden = false; - closeBtn.onclick = () => { - banner.hidden = true; - try { - localStorage.setItem(storageKey(location.href), '1'); - } catch (_e) { /* best-effort */ } - }; - } else { - closeBtn.hidden = true; - } - } - - banner.hidden = false; + // Reused class lets the Playwright spec target this specific gritter without + // affecting its appearance — the gritter looks like every other gritter on + // the page. + $.gritter.add({ + title: config.title || '', + text: buildBody(config), + sticky: true, + position: 'bottom', + class_name: 'privacy-notice', + before_close: () => { + if (config.dismissal !== 'dismissible') return; + try { + localStorage.setItem(storageKey(location.href), '1'); + } catch (_e) { /* best-effort */ } + }, + }); }; + +// End-to-end test hook. The privacy_banner module is bundled into pad.js so +// the Playwright spec at src/tests/frontend-new/specs/privacy_banner.spec.ts +// has no other way to reach into the real showPrivacyBannerIfEnabled — without +// this it can only toy with the DOM and never proves the config-to-DOM wiring. +// Namespaced under __etherpad_privacyBanner__ so it can't collide with site +// code. +(globalThis as any).__etherpad_privacyBanner__ = {show: showPrivacyBannerIfEnabled}; diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 1b105c6614d..cc6794a6690 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -175,50 +175,3 @@ font-family: monospace; padding: 0.4rem; } - -/* GDPR privacy banner (PR4) */ -/* `.privacy-banner[hidden]` (class+attr, specificity 0,2,0) outranks the - * `.privacy-banner { display: flex }` rule below (0,1,0), so the UA `[hidden]` - * default isn't clobbered and we don't need `!important`. */ -.privacy-banner[hidden] { - display: none; -} - -.privacy-banner { - display: flex; - align-items: flex-start; - gap: 0.75rem; - margin: 0.5rem 1rem; - padding: 0.75rem 1rem; - background-color: #fff7d6; - border: 1px solid #e0c97a; - border-radius: 4px; - color: #333; - font-size: 0.9rem; -} - -.privacy-banner .privacy-banner-content { - flex: 1; -} - -.privacy-banner .privacy-banner-title { - display: block; - margin-bottom: 0.25rem; -} - -.privacy-banner .privacy-banner-body p { - margin: 0.2rem 0; -} - -.privacy-banner .privacy-banner-link a { - text-decoration: underline; -} - -.privacy-banner .privacy-banner-close { - background: transparent; - border: 0; - font-size: 1.4rem; - line-height: 1; - cursor: pointer; - color: inherit; -} diff --git a/src/templates/pad.html b/src/templates/pad.html index 5fb0dc3a485..ecac35ad607 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -88,16 +88,6 @@ <% e.begin_block("afterEditbar"); %><% e.end_block(); %> -

-
<% e.begin_block("editorContainerBox"); %> diff --git a/src/tests/frontend-new/specs/privacy_banner.spec.ts b/src/tests/frontend-new/specs/privacy_banner.spec.ts index a5f6a7a9b71..38a02250477 100644 --- a/src/tests/frontend-new/specs/privacy_banner.spec.ts +++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts @@ -1,106 +1,199 @@ import {expect, test, Page} from '@playwright/test'; import {randomUUID} from 'node:crypto'; +type BannerConfig = { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', +}; + +const STORAGE_PREFIX = 'etherpad.privacyBanner.dismissed:'; +// All gritters render into #gritter-container.bottom for this feature; we tag +// our gritter with `class_name: 'privacy-notice'` so tests can target it +// regardless of whatever else the pad may surface. +const NOTICE = '#gritter-container.bottom .gritter-item.privacy-notice'; + const freshPad = 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'); + // Drop any persisted dismissal flag from a previous test run on this origin + // so dismissible scenarios start from a clean state regardless of order. + await page.evaluate((prefix) => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) localStorage.removeItem(k); + } + }, STORAGE_PREFIX); return padId; }; -test.describe('privacy banner', () => { +const showBanner = (page: Page, config: BannerConfig) => + page.evaluate((cfg) => { + (window as any).__etherpad_privacyBanner__.show(cfg); + }, config); + +test.describe('privacy banner (gritter-based)', () => { test.beforeEach(async ({context}) => { await context.clearCookies(); }); - test('disabled by default — banner stays hidden', async ({page}) => { + test('disabled by default — no privacy gritter is shown', async ({page}) => { await freshPad(page); - await expect(page.locator('#privacy-banner')).toBeHidden(); + await expect(page.locator(NOTICE)).toHaveCount(0); }); - test('sticky banner is visible and has no close button', async ({page}) => { + test('enabled=false leaves the page free of a privacy gritter', async ({page}) => { await freshPad(page); - await page.evaluate(() => { - const banner = document.getElementById('privacy-banner')!; - banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; - const body = banner.querySelector('.privacy-banner-body')!; - body.textContent = ''; - const p = document.createElement('p'); - p.textContent = 'Body text'; - body.appendChild(p); - (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; - banner.hidden = false; + await showBanner(page, { + enabled: false, + title: 'Should not render', + body: 'Should not render', + learnMoreUrl: null, + dismissal: 'sticky', }); - await expect(page.locator('#privacy-banner')).toBeVisible(); - await expect(page.locator('#privacy-banner-close')).toBeHidden(); + await expect(page.locator(NOTICE)).toHaveCount(0); }); - test('dismissible — close button hides and persists in localStorage', + test('renders title, body paragraphs, and link as a sticky bottom gritter', + async ({page}) => { + await freshPad(page); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'First paragraph.\nSecond paragraph.', + learnMoreUrl: 'https://example.com/privacy', + dismissal: 'sticky', + }); + const item = page.locator(NOTICE); + await expect(item).toBeVisible(); + await expect(item).toHaveClass(/sticky/); + await expect(item.locator('.gritter-title')).toHaveText('Privacy notice'); + // The body lines become two

s; the optional link adds a third. + const paragraphs = item.locator('.gritter-content > p, .gritter-content div p'); + await expect(paragraphs).toHaveCount(3); + await expect(paragraphs.nth(0)).toHaveText('First paragraph.'); + await expect(paragraphs.nth(1)).toHaveText('Second paragraph.'); + const link = item.locator('a'); + await expect(link).toHaveAttribute('href', 'https://example.com/privacy'); + await expect(link).toHaveAttribute('rel', 'noopener'); + await expect(link).toHaveAttribute('target', '_blank'); + }); + + test('dismissible — clicking gritter close persists flag in localStorage', async ({page}) => { await freshPad(page); - await page.evaluate(() => { - const banner = document.getElementById('privacy-banner')!; - banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; - const body = banner.querySelector('.privacy-banner-body')!; - body.textContent = ''; - const p = document.createElement('p'); - p.textContent = 'Body text'; - body.appendChild(p); - const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; - close.hidden = false; - close.onclick = () => { - banner.hidden = true; - localStorage.setItem( - `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); - }; - banner.hidden = false; + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: null, + dismissal: 'dismissible', }); - await page.locator('#privacy-banner-close').click(); - await expect(page.locator('#privacy-banner')).toBeHidden(); + const item = page.locator(NOTICE); + await expect(item).toBeVisible(); + await item.locator('.gritter-close').click(); + await expect(page.locator(NOTICE)).toHaveCount(0); const flag = await page.evaluate( - () => localStorage.getItem( - `etherpad.privacyBanner.dismissed:${location.origin}`)); + (prefix) => localStorage.getItem(`${prefix}${location.origin}`), + STORAGE_PREFIX); expect(flag).toBe('1'); }); - test('javascript: learnMoreUrl is rejected; https is allowed', async ({page}) => { + test('dismissible — pre-existing localStorage flag suppresses the gritter', + async ({page}) => { + await freshPad(page); + await page.evaluate( + (prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'), + STORAGE_PREFIX); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: null, + dismissal: 'dismissible', + }); + await expect(page.locator(NOTICE)).toHaveCount(0); + }); + + test('sticky — closing the gritter does NOT persist a dismissal flag', + async ({page}) => { + // sticky mode means "show on every load"; the close button still + // works for the current session but must not store a flag. + await freshPad(page); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: null, + dismissal: 'sticky', + }); + const item = page.locator(NOTICE); + await expect(item).toBeVisible(); + await item.locator('.gritter-close').click(); + await expect(page.locator(NOTICE)).toHaveCount(0); + + const flag = await page.evaluate( + (prefix) => localStorage.getItem(`${prefix}${location.origin}`), + STORAGE_PREFIX); + expect(flag).toBeNull(); + }); + + test('sticky — pre-existing localStorage flag is ignored', + async ({page}) => { + await freshPad(page); + await page.evaluate( + (prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'), + STORAGE_PREFIX); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: null, + dismissal: 'sticky', + }); + await expect(page.locator(NOTICE)).toBeVisible(); + }); + + test('javascript: learnMoreUrl is rejected — no anchor rendered', + async ({page}) => { + await freshPad(page); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: 'javascript:alert(1)', + dismissal: 'sticky', + }); + await expect(page.locator(`${NOTICE} a`)).toHaveCount(0); + }); + + test('data: learnMoreUrl is rejected — no anchor rendered', async ({page}) => { + await freshPad(page); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: 'data:text/html,', + dismissal: 'sticky', + }); + await expect(page.locator(`${NOTICE} a`)).toHaveCount(0); + }); + + test('mailto: learnMoreUrl is allowed', async ({page}) => { await freshPad(page); - const results = await page.evaluate(async () => { - // Load the compiled privacy_banner module and call - // showPrivacyBannerIfEnabled with a javascript:/https: URL each time; - // assert that the resulting is either missing (blocked) - // or points at the safe URL. - const bannerEl = document.getElementById('privacy-banner')!; - const linkEl = bannerEl.querySelector('.privacy-banner-link') as HTMLElement; - - const run = (url: string) => { - linkEl.replaceChildren(); - const SAFE = new Set(['http:', 'https:', 'mailto:']); - let safe: string | null = null; - try { - const parsed = new URL(url, location.href); - if (SAFE.has(parsed.protocol)) safe = parsed.href; - } catch (_e) { /* not a URL — leave safe=null */ } - if (safe != null) { - const a = document.createElement('a'); - a.href = safe; - linkEl.appendChild(a); - } - const a = linkEl.querySelector('a'); - return a ? a.getAttribute('href') : null; - }; - return { - javascript: run('javascript:alert(1)'), - dataUrl: run('data:text/html,'), - https: run('https://example.com/privacy'), - mailto: run('mailto:privacy@example.com'), - }; + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: 'mailto:privacy@example.com', + dismissal: 'sticky', }); - expect(results.javascript).toBeNull(); - expect(results.dataUrl).toBeNull(); - expect(results.https).toBe('https://example.com/privacy'); - expect(results.mailto).toBe('mailto:privacy@example.com'); + await expect(page.locator(`${NOTICE} a`)) + .toHaveAttribute('href', 'mailto:privacy@example.com'); }); }); From 8abb7e6d7f92560d74a02451fd5b849ba55168c3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 06:22:18 +0100 Subject: [PATCH 13/15] fix(privacy-banner): noreferrer + validate dismissal (Qodo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Qodo's review on #7549: 1. The Learn-more link now sets `rel="noreferrer noopener"` (was just `noopener`). Without `noreferrer` the browser sends the pad URL as a Referer to the operator-configured external policy site, which leaks pad identifiers to a third party. Matches the rel pattern already used by pad_utils.ts. 2. `privacyBanner.dismissal` is now validated in reloadSettings(): an unknown value falls back to 'dismissible' with a `logger.warn`, in the same shape as the existing ipLogging validation a few lines up. The client also guards defensively (treats anything other than the exact string 'sticky' as 'dismissible') so that hot-reload paths that skip the server validator can't silently degrade a typo'd 'sticky' into "no close button persisted, no localStorage suppression". Test added: spec asserts the rel attribute, and a new test exercises the dismissal fallback (sets dismissal:'wat', asserts the gritter is shown, the (X) closes it, and the dismissal flag is persisted — i.e. the unknown value is treated like 'dismissible'). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/utils/Settings.ts | 15 +++++++++++ src/static/js/privacy_banner.ts | 17 ++++++++++--- .../frontend-new/specs/privacy_banner.spec.ts | 25 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index f14c969d135..e9f32051dd6 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -1049,6 +1049,21 @@ export const reloadSettings = () => { settings.ipLogging = 'anonymous'; } + // Validate `privacyBanner.dismissal`. The client treats every value other + // than the exact strings 'dismissible' and 'sticky' as "no special + // handling", which silently degrades a misconfigured 'sticky' to a + // dismissible-shaped notice (and vice versa). Coerce to the safer default + // and warn so the operator sees the typo. + const validDismissal = ['dismissible', 'sticky']; + if (settings.privacyBanner != null + && !validDismissal.includes(settings.privacyBanner.dismissal as any)) { + logger.warn( + `privacyBanner.dismissal="${settings.privacyBanner.dismissal}" is ` + + `not one of ${validDismissal.join(', ')}; falling back to ` + + `"dismissible".`); + settings.privacyBanner.dismissal = 'dismissible'; + } + // Init logging config settings.logconfig = defaultLogConfig( settings.loglevel ? settings.loglevel : defaultLogLevel, diff --git a/src/static/js/privacy_banner.ts b/src/static/js/privacy_banner.ts index d07701e3f65..38987dacd47 100644 --- a/src/static/js/privacy_banner.ts +++ b/src/static/js/privacy_banner.ts @@ -44,11 +44,15 @@ const buildBody = (config: BannerConfig): JQuery => { } const safeHref = safeUrl(config.learnMoreUrl); if (safeHref != null) { + // `noreferrer` matches the existing pattern in pad_utils.ts so the pad + // URL doesn't leak to the operator-configured external policy site as a + // Referer header. `noopener` keeps target=_blank from sharing the + // window.opener handle. wrap.append($('

').append( $('') .attr('href', safeHref) .attr('target', '_blank') - .attr('rel', 'noopener') + .attr('rel', 'noreferrer noopener') .text('Learn more'))); } return wrap; @@ -59,7 +63,14 @@ export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => const $ = (window as any).$; if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return; - if (config.dismissal === 'dismissible') { + // Server-side reloadSettings() coerces unknown values to 'dismissible' with a + // warn, but if a custom build / hot-reload path skips that validation we + // still must not fall through to "treats unknown as sticky" (which is the + // less safe interpretation — an operator who fat-fingered "dismisable" + // probably meant the dismissable mode they wrote). + const dismissal = config.dismissal === 'sticky' ? 'sticky' : 'dismissible'; + + if (dismissal === 'dismissible') { try { if (localStorage.getItem(storageKey(location.href)) === '1') return; } catch (_e) { /* proceed without persistence */ } @@ -75,7 +86,7 @@ export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => position: 'bottom', class_name: 'privacy-notice', before_close: () => { - if (config.dismissal !== 'dismissible') return; + if (dismissal !== 'dismissible') return; try { localStorage.setItem(storageKey(location.href), '1'); } catch (_e) { /* best-effort */ } diff --git a/src/tests/frontend-new/specs/privacy_banner.spec.ts b/src/tests/frontend-new/specs/privacy_banner.spec.ts index 38a02250477..907a4057483 100644 --- a/src/tests/frontend-new/specs/privacy_banner.spec.ts +++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts @@ -79,7 +79,7 @@ test.describe('privacy banner (gritter-based)', () => { await expect(paragraphs.nth(1)).toHaveText('Second paragraph.'); const link = item.locator('a'); await expect(link).toHaveAttribute('href', 'https://example.com/privacy'); - await expect(link).toHaveAttribute('rel', 'noopener'); + await expect(link).toHaveAttribute('rel', 'noreferrer noopener'); await expect(link).toHaveAttribute('target', '_blank'); }); @@ -184,6 +184,29 @@ test.describe('privacy banner (gritter-based)', () => { await expect(page.locator(`${NOTICE} a`)).toHaveCount(0); }); + test('unknown dismissal value is treated as dismissible (defense-in-depth)', + async ({page}) => { + // Server-side reloadSettings() coerces unknown strings to + // 'dismissible' with a warn, but the client guards too in case a + // hot-reload or custom build path skips that validation. + await freshPad(page); + await showBanner(page, { + enabled: true, + title: 'Privacy notice', + body: 'Body.', + learnMoreUrl: null, + dismissal: 'wat' as any, + }); + const item = page.locator(NOTICE); + await expect(item).toBeVisible(); + await item.locator('.gritter-close').click(); + await expect(page.locator(NOTICE)).toHaveCount(0); + const flag = await page.evaluate( + (prefix) => localStorage.getItem(`${prefix}${location.origin}`), + STORAGE_PREFIX); + expect(flag).toBe('1'); + }); + test('mailto: learnMoreUrl is allowed', async ({page}) => { await freshPad(page); await showBanner(page, { From c206e2aa6c064299fcf689b1fc4bdc0d26df4c02 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 06:28:10 +0100 Subject: [PATCH 14/15] fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Qodo's second review on #7549. Rule violation: __etherpad_privacyBanner__ was published on every pad load even when privacyBanner.enabled was false, so the disabled-by- default feature still added an observable global. Gate the assignment on `navigator.webdriver` — Playwright/ChromeDriver/Selenium set this to true; production browsers do not — so the hook is only present for tests and the disabled path is genuinely zero-side-effect. Bug 3 (sticky still closable): doc/privacy.md previously claimed `dismissal: "sticky"` removes the close button, but the gritter implementation always renders (X). Aligning the doc with reality — sticky now means "shows on every load, but closable for the session" — rather than adding bespoke CSS to a vanilla gritter (matches the "don't style it differently than other gritter messages" preference that drove the gritter migration in 906e145). Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/privacy.md | 23 ++++++++++++++++++----- src/static/js/privacy_banner.ts | 9 ++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/doc/privacy.md b/doc/privacy.md index c7fe8a6ac02..2dab364cc49 100644 --- a/doc/privacy.md +++ b/doc/privacy.md @@ -76,8 +76,21 @@ policy, contact for erasure requests, etc. } ``` -The banner is rendered from plain text (HTML is escaped) with one -paragraph per line. With `dismissal: "dismissible"` the user can close -the banner and the choice is remembered in `localStorage` per origin. -`dismissal: "sticky"` removes the close button so the notice is shown -on every pad load. +The banner is rendered as a persistent gritter notification at the +bottom of the page (it inherits the same look as every other gritter +on the pad — no custom skin needed). The body is plain text (HTML is +escaped); each line becomes its own paragraph. + +`dismissal` controls how the close (×) is handled: + +- `"dismissible"` (default) — when the user closes the gritter, the + choice is persisted in `localStorage` per origin and the banner is + not shown again on subsequent pad loads. +- `"sticky"` — closing the gritter only hides it for the current + session; the next pad load shows it again. (The close control is + not removed; for an operator-enforced non-closable notice, render + the policy out-of-band — e.g., a skin override or a reverse-proxy + ribbon.) + +Unknown `dismissal` values are coerced to `"dismissible"` with a +`logger.warn` at settings load. diff --git a/src/static/js/privacy_banner.ts b/src/static/js/privacy_banner.ts index 38987dacd47..867bf7eaf0d 100644 --- a/src/static/js/privacy_banner.ts +++ b/src/static/js/privacy_banner.ts @@ -98,6 +98,9 @@ export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => // the Playwright spec at src/tests/frontend-new/specs/privacy_banner.spec.ts // has no other way to reach into the real showPrivacyBannerIfEnabled — without // this it can only toy with the DOM and never proves the config-to-DOM wiring. -// Namespaced under __etherpad_privacyBanner__ so it can't collide with site -// code. -(globalThis as any).__etherpad_privacyBanner__ = {show: showPrivacyBannerIfEnabled}; +// Gated on navigator.webdriver so the global is invisible in real browsers +// (Playwright/ChromeDriver/Selenium set webdriver=true; humans don't), keeping +// the disabled-by-default feature genuinely zero-side-effect in production. +if (typeof navigator !== 'undefined' && (navigator as any).webdriver) { + (globalThis as any).__etherpad_privacyBanner__ = {show: showPrivacyBannerIfEnabled}; +} From b12ab1d8e595aaf3bbb8da62799772c8a2b7ea71 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 06:30:53 +0100 Subject: [PATCH 15/15] fix(privacy-banner): allow-list keys before sending to clientVars (Qodo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeSettings() merges nested objects with _.defaults() and preserves unknown nested keys, and TypeScript's Pick<> doesn't strip at runtime. The previous wire path forwarded settings.privacyBanner by reference into both clientVars and getPublicSettings(), so any extra keys an operator typed (or pasted) under privacyBanner — credentials, internal notes, anything — would have shipped to every browser on every pad load. Adds getPublicPrivacyBanner() in Settings.ts that returns a literal with only {enabled, title, body, learnMoreUrl, dismissal}, and uses it from both leak sites (PadMessageHandler.ts clientVars and getPublicSettings()). Single source of truth for the wire shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/handler/PadMessageHandler.ts | 6 +++++- src/node/utils/Settings.ts | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 09b2480311f..5fe717615b9 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -33,6 +33,7 @@ import padutils from '../../static/js/pad_utils'; import readOnlyManager from '../db/ReadOnlyManager'; import settings, { exportAvailable, + getPublicPrivacyBanner, sofficeAvailable } from '../utils/Settings'; import {anonymizeIp} from '../utils/anonymizeIp'; @@ -1157,7 +1158,10 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { enableDarkMode: settings.enableDarkMode, enablePadWideSettings: settings.enablePadWideSettings, padDeletionToken, - privacyBanner: settings.privacyBanner, + // Allow-listed copy — settings.privacyBanner could carry extra nested + // keys from a hand-edited settings.json; sending those by reference + // would leak them to every browser. See getPublicPrivacyBanner(). + privacyBanner: getPublicPrivacyBanner(), automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], initialOptions: pad.getPadSettings(), diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index e9f32051dd6..23b1e593984 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -733,12 +733,25 @@ const settings: SettingsType = { skinName: settings.skinName, skinVariants: settings.skinVariants, enablePadWideSettings: settings.enablePadWideSettings, - privacyBanner: settings.privacyBanner, + privacyBanner: getPublicPrivacyBanner(), } }, gitVersion: getGitCommit(), } +// Build the wire-shape of `privacyBanner` for clientVars / getPublicSettings(). +// The settings file is operator-controlled and `_.defaults()` (used by +// storeSettings) preserves unknown nested keys at runtime. Returning a literal +// instead of `settings.privacyBanner` itself stops a typo or copy-paste from +// shipping arbitrary extra keys to every browser. +export const getPublicPrivacyBanner = () => ({ + enabled: settings.privacyBanner.enabled, + title: settings.privacyBanner.title, + body: settings.privacyBanner.body, + learnMoreUrl: settings.privacyBanner.learnMoreUrl, + dismissal: settings.privacyBanner.dismissal, +}); + export default settings; // CJS compatibility: plugins use require('ep_etherpad-lite/node/utils/Settings') // and expect settings properties directly on the module object, not under .default