` 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 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/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts
index 409055e606e..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,6 +1158,10 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
padDeletionToken,
+ // 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 7cd42a81185..23b1e593984 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,11 +733,25 @@ const settings: SettingsType = {
skinName: settings.skinName,
skinVariants: settings.skinVariants,
enablePadWideSettings: settings.enablePadWideSettings,
+ 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
@@ -1033,6 +1062,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/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..867bf7eaf0d
--- /dev/null
+++ b/src/static/js/privacy_banner.ts
@@ -0,0 +1,106 @@
+'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;
+ 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;
+};
+
+// 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) {
+ // `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', 'noreferrer noopener')
+ .text('Learn more')));
+ }
+ return wrap;
+};
+
+export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
+ if (!config || !config.enabled) return;
+ const $ = (window as any).$;
+ if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
+
+ // 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 */ }
+ }
+
+ // 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 (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.
+// 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};
+}
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: {
diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css
index 9d61d51764b..cc6794a6690 100644
--- a/src/static/skins/colibris/src/components/popup.css
+++ b/src/static/skins/colibris/src/components/popup.css
@@ -175,4 +175,3 @@
font-family: monospace;
padding: 0.4rem;
}
-
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..907a4057483
--- /dev/null
+++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts
@@ -0,0 +1,222 @@
+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;
+};
+
+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 — no privacy gritter is shown', async ({page}) => {
+ await freshPad(page);
+ await expect(page.locator(NOTICE)).toHaveCount(0);
+ });
+
+ test('enabled=false leaves the page free of a privacy gritter', async ({page}) => {
+ await freshPad(page);
+ await showBanner(page, {
+ enabled: false,
+ title: 'Should not render',
+ body: 'Should not render',
+ learnMoreUrl: null,
+ dismissal: 'sticky',
+ });
+ await expect(page.locator(NOTICE)).toHaveCount(0);
+ });
+
+ 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', 'noreferrer noopener');
+ await expect(link).toHaveAttribute('target', '_blank');
+ });
+
+ test('dismissible — clicking gritter close persists flag in localStorage',
+ async ({page}) => {
+ await freshPad(page);
+ await showBanner(page, {
+ enabled: true,
+ title: 'Privacy notice',
+ body: 'Body.',
+ learnMoreUrl: null,
+ dismissal: 'dismissible',
+ });
+ 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('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('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, {
+ enabled: true,
+ title: 'Privacy notice',
+ body: 'Body.',
+ learnMoreUrl: 'mailto:privacy@example.com',
+ dismissal: 'sticky',
+ });
+ await expect(page.locator(`${NOTICE} a`))
+ .toHaveAttribute('href', 'mailto:privacy@example.com');
+ });
+});