` rendered with the right href, absent when
+ null.
+ - Body with two `\n\n` paragraphs → two `` 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.
diff --git a/settings.json.docker b/settings.json.docker
index 8fdd51de01e..e4d51d2e22c 100644
--- a/settings.json.docker
+++ b/settings.json.docker
@@ -211,6 +211,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 0d1493c2b40..a00b46d0a67 100644
--- a/settings.json.template
+++ b/settings.json.template
@@ -649,6 +649,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/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts
index 8285a3a8a52..2194f2a5f0d 100644
--- a/src/node/handler/PadMessageHandler.ts
+++ b/src/node/handler/PadMessageHandler.ts
@@ -1081,6 +1081,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
},
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
+ privacyBanner: settings.privacyBanner,
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
initialRevisionList: [],
initialOptions: pad.getPadSettings(),
diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts
index 0b250e494c3..66bf9b9ea6e 100644
--- a/src/node/utils/Settings.ts
+++ b/src/node/utils/Settings.ts
@@ -173,6 +173,13 @@ export type SettingsType = {
updateServer: string,
enableDarkMode: boolean,
enablePadWideSettings: boolean,
+ privacyBanner: {
+ enabled: boolean,
+ title: string,
+ body: string,
+ learnMoreUrl: string | null,
+ dismissal: 'dismissible' | 'sticky',
+ },
skinName: string | null,
skinVariants: string,
ip: string,
@@ -295,7 +302,7 @@ export type SettingsType = {
lowerCasePadIds: boolean,
randomVersionString: string,
gitVersion: string
- getPublicSettings: () => Pick,
+ getPublicSettings: () => Pick,
}
const settings: SettingsType = {
@@ -330,6 +337,14 @@ const settings: SettingsType = {
updateServer: "https://static.etherpad.org",
enableDarkMode: true,
enablePadWideSettings: 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.
*
@@ -660,6 +675,7 @@ const settings: SettingsType = {
skinName: settings.skinName,
skinVariants: settings.skinVariants,
enablePadWideSettings: settings.enablePadWideSettings,
+ privacyBanner: settings.privacyBanner,
}
},
gitVersion: getGitCommit(),
diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts
index d9698f5e776..d42332b49f1 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';
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
@@ -639,6 +640,8 @@ const pad = {
$('#options-darkmode').prop('checked', skinVariants.isDarkMode());
}
+ 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..e15e6efdb08
--- /dev/null
+++ b/src/static/js/privacy_banner.ts
@@ -0,0 +1,91 @@
+'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';
+ }
+};
+
+// Only http(s) and mailto: are allowed for the "Learn more" link, so a
+// misconfigured privacyBanner.learnMoreUrl cannot smuggle a javascript:,
+// data:, or vbscript: URL into the anchor and execute script on click.
+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);
+ } catch (_e) {
+ return null;
+ }
+ if (!SAFE_URL_SCHEMES.has(parsed.protocol)) return null;
+ return parsed.href;
+};
+
+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();
+ 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;
+};
diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts
index 08be6a03ee5..b47a2024c81 100644
--- a/src/static/js/types/SocketIOMessage.ts
+++ b/src/static/js/types/SocketIOMessage.ts
@@ -63,6 +63,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,
clientIp: string,
colorPalette: string[],
diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css
index 381c10d8726..627c3295626 100644
--- a/src/static/skins/colibris/src/components/popup.css
+++ b/src/static/skins/colibris/src/components/popup.css
@@ -114,3 +114,47 @@
#delete-pad {
margin-top: 20px;
}
+
+/* GDPR privacy banner (PR4) */
+.privacy-banner[hidden] {
+ display: none !important;
+}
+
+.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 5e593f6d7aa..c6e326d1a2c 100644
--- a/src/templates/pad.html
+++ b/src/templates/pad.html
@@ -76,6 +76,16 @@
<% 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
new file mode 100644
index 00000000000..a5f6a7a9b71
--- /dev/null
+++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts
@@ -0,0 +1,106 @@
+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');
+ });
+
+ test('javascript: learnMoreUrl is rejected; https 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'),
+ };
+ });
+ expect(results.javascript).toBeNull();
+ expect(results.dataUrl).toBeNull();
+ expect(results.https).toBe('https://example.com/privacy');
+ expect(results.mailto).toBe('mailto:privacy@example.com');
+ });
+});