mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
* docs: PR4 GDPR privacy banner design spec
* docs: PR4 GDPR privacy banner implementation plan
* feat(gdpr): typed privacyBanner setting block + public getter exposure
* feat(gdpr): send privacyBanner config to the browser via clientVars
* feat(gdpr): privacy banner DOM (hidden by default)
* feat(gdpr): render privacy banner on pad load when enabled
* style(gdpr): privacy banner layout
* test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override
* docs(gdpr): privacyBanner configuration section
* fix(gdpr): reject unsafe learnMoreUrl schemes
Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl
directly to <a href>, so a misconfigured settings.privacyBanner.
learnMoreUrl of `javascript:alert(1)` or `data:…<script>…` would run
script on click. Validate via URL parsing and allow only http(s) /
mailto; everything else yields no link. Playwright regression guards
the four cases (javascript, data, https, mailto).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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.
* 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) <noreply@anthropic.com>
* fix(privacy-banner): noreferrer + validate dismissal (Qodo)
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) <noreply@anthropic.com>
* fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior
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) <noreply@anthropic.com>
* fix(privacy-banner): allow-list keys before sending to clientVars (Qodo)
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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.2 KiB
6.2 KiB
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.jsonblock 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
colibrisskin 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
"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 <br>.
*/
"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 fromprivacyBannerleaks.PadMessageHandleralready sendssettings.getPublicSettings()viaclientVars.skinNameetc. — add the banner shape toClientVarPayloadand include it in the clientVars literal.
Template
- Add
<div id="privacy-banner" hidden>tosrc/templates/pad.html, styled by the colibris skin. Collapsed by default. - Contents: title
<strong>, body<p>(each line becomes a<p>so newlines behave), optional<a target="_blank" rel="noopener">, and a<button id="privacy-banner-close">that's rendered only ifdismissal === "dismissible". - Body text is written via textContent (not innerHTML) to avoid XSS.
Client JS
New 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 =>
`etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
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' &&
localStorage.getItem(storageKey(location.href)) === '1') {
return;
}
(banner.querySelector('.privacy-banner-title') as HTMLElement).textContent =
config.title;
const bodyHost = banner.querySelector('.privacy-banner-body') as HTMLElement;
bodyHost.textContent = '';
for (const line of config.body.split(/\r?\n/)) {
const p = document.createElement('p');
p.textContent = line;
bodyHost.appendChild(p);
}
const linkHost = banner.querySelector('.privacy-banner-link') as HTMLElement;
if (config.learnMoreUrl) {
const a = document.createElement('a');
a.href = config.learnMoreUrl;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Learn more';
linkHost.replaceChildren(a);
} else {
linkHost.replaceChildren();
}
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLElement | null;
if (config.dismissal === 'dismissible' && closeBtn) {
closeBtn.hidden = false;
closeBtn.addEventListener('click', () => {
banner.hidden = true;
try { localStorage.setItem(storageKey(location.href), '1'); } catch (_e) { /* best effort */ }
});
} else if (closeBtn) {
closeBtn.hidden = true;
}
banner.hidden = false;
};
Called from pad.ts once after postAceInit, with
clientVars.privacyBanner.
Tests
- Settings unit (
src/tests/backend/specs/privacyBanner.ts): default shape matches, malformeddismissalfalls back to'dismissible'on load. - Playwright
(
src/tests/frontend-new/specs/privacy_banner.spec.ts):- disabled (default) →
#privacy-bannerstayshidden. - enabled +
sticky→ banner visible on load, no close button. - enabled +
dismissible→ close button toggles banner hidden and persists across reload via localStorage. learnMoreUrl→<a>rendered with the right href, absent when null.- Body with two
\n\nparagraphs → two<p>children.
- disabled (default) →
Tests flip settings.privacyBanner.enabled at runtime and navigate to
a fresh pad; no server restart needed.
Docs
- Add a short section to
doc/privacy.mddescribing the banner and how to configure it. - Add a one-line pointer from
doc/settings.md's existing layout to the privacy doc ifsettings.mdhas a section for this kind of block; otherwise leavesettings.json.template's inline comments as the authoritative reference.
Risk / migration
- Default
enabled: falsekeeps the UI quiet for every existing instance. - Plain-text + textContent rendering avoids XSS even if operators
copy-paste raw HTML into
body. - localStorage key is scoped per-origin, so multi-tenant proxy setups won't cross-contaminate dismissal state.