Skip to content

feat(gdpr): configurable privacy banner (PR4 of #6701)#7549

Open
JohnMcLear wants to merge 10 commits intodevelopfrom
feat-gdpr-privacy-banner
Open

feat(gdpr): configurable privacy banner (PR4 of #6701)#7549
JohnMcLear wants to merge 10 commits intodevelopfrom
feat-gdpr-privacy-banner

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

Summary

  • New privacyBanner block in settings.json (enabled / title / body / learnMoreUrl / dismissal). Defaults to disabled so existing instances are unchanged.
  • Banner config ships to the browser via clientVars.privacyBanner; client renders it with textContent (HTML is escaped).
  • dismissible stores a per-origin flag in localStorage so the user sees the notice once; sticky shows it every load.

Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already in flight. 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

  • pnpm --filter ep_etherpad-lite run ts-check
  • Playwright — disabled-by-default / sticky / dismissible-with-persistence — 3 passing
  • Chat regression — passes

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Add configurable GDPR privacy banner with dismissible/sticky modes

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add configurable privacy banner to settings.json with title, body, learn-more link, and
  dismissal mode
• Wire banner config through clientVars to browser for rendering on pad load
• Implement client-side banner rendering with XSS-safe textContent and per-origin localStorage
  persistence
• Add Playwright tests covering disabled-by-default, sticky, and dismissible modes with localStorage
  verification
• Include comprehensive documentation and styling for the colibris skin
Diagram
flowchart LR
  Settings["Settings.ts<br/>privacyBanner block"] -- "getPublicSettings()" --> ClientVars["ClientVars<br/>privacyBanner payload"]
  ClientVars -- "sent to browser" --> PadHTML["pad.html<br/>hidden banner DOM"]
  PadHTML -- "populated by" --> PrivacyBanner["privacy_banner.ts<br/>showPrivacyBannerIfEnabled"]
  PrivacyBanner -- "textContent + localStorage" --> Rendered["Rendered banner<br/>dismissible or sticky"]
Loading

Grey Divider

File Changes

1. src/node/utils/Settings.ts ✨ Enhancement +17/-1

Add typed privacyBanner block with defaults and public exposure

src/node/utils/Settings.ts


2. src/node/handler/PadMessageHandler.ts ✨ Enhancement +1/-0

Include privacyBanner config in clientVars payload

src/node/handler/PadMessageHandler.ts


3. src/static/js/types/SocketIOMessage.ts ✨ Enhancement +7/-0

Add privacyBanner type to ClientVarPayload interface

src/static/js/types/SocketIOMessage.ts


View more (10)
4. src/static/js/privacy_banner.ts ✨ Enhancement +72/-0

New module rendering banner with dismissal and localStorage persistence

src/static/js/privacy_banner.ts


5. src/static/js/pad.ts ✨ Enhancement +3/-0

Import and call privacy banner renderer after pad initialization

src/static/js/pad.ts


6. src/templates/pad.html ✨ Enhancement +10/-0

Add hidden privacy banner DOM structure with title, body, link, close button

src/templates/pad.html


7. src/static/skins/colibris/src/components/popup.css ✨ Enhancement +44/-0

Style privacy banner with flexbox layout and dismissal button

src/static/skins/colibris/src/components/popup.css


8. src/tests/frontend-new/specs/privacy_banner.spec.ts 🧪 Tests +67/-0

Playwright tests for disabled, sticky, and dismissible banner modes

src/tests/frontend-new/specs/privacy_banner.spec.ts


9. settings.json.template ⚙️ Configuration changes +18/-0

Add privacyBanner configuration block with inline documentation

settings.json.template


10. settings.json.docker ⚙️ Configuration changes +11/-0

Add privacyBanner block with environment variable substitution

settings.json.docker


11. doc/privacy.md 📝 Documentation +29/-0

Document privacy banner configuration and usage

doc/privacy.md


12. docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md 📝 Documentation +183/-0

Design specification for GDPR privacy banner feature

docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md


13. docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md 📝 Documentation +595/-0

Detailed implementation plan with task-by-task checklist

docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects bot commented Apr 19, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Unsafe learnMoreUrl href🐞 Bug ⛨ Security
Description
showPrivacyBannerIfEnabled() assigns settings-provided learnMoreUrl directly to an anchor href, so a
javascript:/data: URL would execute script when clicked. This creates a user-triggered XSS vector
via configuration.
Code

src/static/js/privacy_banner.ts[R45-53]

+    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);
+    }
Evidence
The client code writes config.learnMoreUrl directly into a DOM anchor without validating allowed
schemes, and the value is operator-configurable via settings and shipped to the browser via
clientVars.

src/static/js/privacy_banner.ts[43-53]
src/node/utils/Settings.ts[159-181]
src/node/handler/PadMessageHandler.ts[1028-1037]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/static/js/privacy_banner.ts` sets `a.href` from `config.learnMoreUrl` without validating the URL scheme. This allows `javascript:` (and similar) URLs to execute code if a user clicks the “Learn more” link.
### Issue Context
`learnMoreUrl` is operator-controlled via `settings.json` and is sent to browsers via `clientVars`, so the client must defensively validate it.
### Fix Focus Areas
- src/static/js/privacy_banner.ts[43-54]
### Suggested fix
- Parse `config.learnMoreUrl` with `new URL()` (using `location.href` as base for relative URLs).
- Allowlist safe protocols (e.g., `http:`, `https:`; optionally `mailto:` depending on product policy).
- If invalid/unsafe, do not render the link (and optionally log a console warning in development).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unvalidated dismissal values 🐞 Bug ◔ Observability
Description
If privacyBanner.dismissal is set to an unexpected value at runtime, the banner silently behaves as
sticky (no close button, no localStorage check), making operator misconfiguration hard to detect.
This behavior is not validated or warned about during settings load.
Code

src/static/js/privacy_banner.ts[R24-28]

+  if (config.dismissal === 'dismissible') {
+    try {
+      if (localStorage.getItem(storageKey(location.href)) === '1') return;
+    } catch (_e) { /* proceed without persistence */ }
+  }
Evidence
Settings loading merges nested objects without validating enum-like fields, and the client logic
only treats the exact string 'dismissible' as dismissible (everything else becomes sticky behavior
without any warning).

src/node/utils/Settings.ts[727-755]
src/static/js/privacy_banner.ts[24-28]
src/static/js/privacy_banner.ts[56-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`privacyBanner.dismissal` is treated as a string at runtime; any value other than `'dismissible'` results in sticky behavior with no warning. This makes misconfiguration silent and hard to debug.
### Issue Context
`storeSettings()` merges nested objects and does not validate nested field values. The client code’s equality check (`=== 'dismissible'`) means unexpected values degrade behavior.
### Fix Focus Areas
- src/node/utils/Settings.ts[955-960]
- src/static/js/privacy_banner.ts[19-70]
### Suggested fix
- Add a small normalization step after `storeSettings(settingsParsed)` in `reloadSettings()`:
- If `settings.privacyBanner.dismissal` is not `'dismissible'` or `'sticky'`, set it to `'dismissible'` (or your desired default) and `logger.warn()`.
- Optionally add a defensive fallback in `showPrivacyBannerIfEnabled()` as well (treat unknown values as `'dismissible'` or explicitly as `'sticky'` but warn).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Privacy banner tests bypass logic 🐞 Bug ⚙ Maintainability
Description
The Playwright sticky and dismissible tests manually mutate DOM and attach onclick handlers, so they
can pass even if privacy_banner.ts is broken or never invoked. This significantly reduces regression
coverage for the actual feature implementation.
Code

src/tests/frontend-new/specs/privacy_banner.spec.ts[R22-58]

+  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;
+        });
Evidence
pad.ts invokes showPrivacyBannerIfEnabled(clientVars.privacyBanner) during postAceInit, but the
tests do not set clientVars.privacyBanner and do not call showPrivacyBannerIfEnabled(); instead they
directly set banner.hidden and close.onclick in page.evaluate().

src/static/js/pad.ts[493-504]
src/tests/frontend-new/specs/privacy_banner.spec.ts[22-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Current Playwright tests for sticky/dismissible behavior manipulate the DOM directly rather than exercising `showPrivacyBannerIfEnabled()`, so they won’t catch regressions in the actual banner implementation.
### Issue Context
`pad.ts` calls `showPrivacyBannerIfEnabled((clientVars as any).privacyBanner)` after editor init. The tests can invoke the module directly after `freshPad()` by setting `window.clientVars.privacyBanner` and calling `showPrivacyBannerIfEnabled()` again.
### Fix Focus Areas
- src/tests/frontend-new/specs/privacy_banner.spec.ts[17-67]
### Suggested fix
- In each non-default test:
- `page.evaluate((cfg) => { (window as any).clientVars.privacyBanner = cfg; /* obtain the module and call showPrivacyBannerIfEnabled(cfg) */ }, cfg)`.
- Prefer invoking the real module rather than manually editing `.textContent`, `hidden`, and `onclick`.
- Add an assertion that dismissal persists by reloading and verifying the banner stays hidden when `dismissible` and localStorage flag is set.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Over-shares privacyBanner object 🐞 Bug ⛨ Security
Description
PadMessageHandler sends settings.privacyBanner by reference into clientVars, so any unexpected
nested keys under privacyBanner in settings.json will be serialized and exposed to browsers.
TypeScript Pick<> does not strip properties at runtime.
Code

src/node/handler/PadMessageHandler.ts[R1033-1037]

      maxRevisions: 100,
    },
    enableDarkMode: settings.enableDarkMode,
+      privacyBanner: settings.privacyBanner,
    automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
Evidence
storeSettings() merges nested objects without removing unknown nested properties, and the server
forwards settings.privacyBanner directly into clientVars. Because serialization is runtime-based,
any extra keys present in the object will be sent.

src/node/utils/Settings.ts[727-755]
src/node/handler/PadMessageHandler.ts[1028-1037]
src/node/utils/Settings.ts[666-676]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`clientVars.privacyBanner` is populated with `settings.privacyBanner` directly. If the settings file includes additional nested keys under `privacyBanner`, they will be included in the object and sent to clients.
### Issue Context
`storeSettings()` uses `_.defaults()` for nested objects, which preserves unknown nested keys. TypeScript types do not enforce runtime shape.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1028-1037]
- src/node/utils/Settings.ts[666-676]
- src/node/utils/Settings.ts[727-755]
### Suggested fix
- When building `clientVars`, construct a new object with only the allowed keys:
- `{enabled, title, body, learnMoreUrl, dismissal}`
- Optionally do the same in `getPublicSettings()` to centralize the public shape.
- (Optional) Freeze/clone to avoid accidental mutation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread src/static/js/privacy_banner.ts
JohnMcLear and others added 10 commits April 19, 2026 11:49
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>
@JohnMcLear JohnMcLear force-pushed the feat-gdpr-privacy-banner branch from ac03929 to 006618b Compare April 19, 2026 10:50
Copy link
Copy Markdown
Member

@SamTV12345 SamTV12345 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides the important flag nothing strikes my eyes. Great job :)


/* GDPR privacy banner (PR4) */
.privacy-banner[hidden] {
display: none !important;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need important here? Maybe a more specific rule can fix this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants