etherpad-lite/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md
John McLear 487842006c
feat(gdpr): configurable privacy banner (PR4 of #6701) (#7549)
* 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>
2026-05-03 13:59:38 +08:00

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

"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 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 <div id="privacy-banner" hidden> to src/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 if dismissal === "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, malformed dismissal falls back to 'dismissible' on load.
  • Playwright (src/tests/frontend-new/specs/privacy_banner.spec.ts):
    • disabled (default) → #privacy-banner stays hidden.
    • 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\n paragraphs → two <p> children.

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.md describing the banner and how to configure it.
  • Add a one-line pointer from doc/settings.md's existing layout to the privacy doc if settings.md has a section for this kind of block; otherwise leave settings.json.template's inline comments as the authoritative reference.

Risk / migration

  • Default enabled: false keeps 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.