From 4eb95c40568503ee0f2ea362c52a0aaa9100a522 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 7 Jun 2026 14:42:36 +0100 Subject: [PATCH 1/3] Escape exported data-* attributes; require explicit deploy credentials - ExportHtml: escape the name and value of attributes emitted by the exportHtmlAdditionalTagsWithData hook, consistent with the URL/text escaping already applied when generating exported HTML. - docker-compose: require ADMIN_PASSWORD and the database password to be set explicitly (no default fallback); default TRUST_PROXY to false. - Settings: log a warning (error level in production) when an account uses a default/placeholder password from the shipped config. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 12 ++++++++---- src/node/utils/ExportHtml.ts | 7 ++++++- src/node/utils/Settings.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 90d2f86b591..653ad9bbeca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,11 +19,13 @@ services: - postgres environment: NODE_ENV: production - ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin} + # Required — set a strong value (e.g. in .env). No fallback, so misconfig + # surfaces at `docker compose up` rather than at runtime. + ADMIN_PASSWORD: "${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:?Set DOCKER_COMPOSE_APP_ADMIN_PASSWORD to a strong value}" DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4} DB_HOST: postgres DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad} - DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin} + DB_PASS: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}" DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432} DB_TYPE: "postgres" DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin} @@ -31,7 +33,9 @@ services: DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- } DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false} SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null} - TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true} + # Default off: only enable when actually behind a trusted reverse proxy + # that sets the X-Forwarded-* headers. + TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false} restart: always ports: - "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}" @@ -40,7 +44,7 @@ services: image: postgres:15-alpine environment: POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad} - POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin} + POSTGRES_PASSWORD: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}" POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432} POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin} PGDATA: /var/lib/postgresql/data/pgdata diff --git a/src/node/utils/ExportHtml.ts b/src/node/utils/ExportHtml.ts index 99d77262039..e356c39bdbd 100644 --- a/src/node/utils/ExportHtml.ts +++ b/src/node/utils/ExportHtml.ts @@ -63,7 +63,12 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string // like hooks.aCallAll('exportHtmlAdditionalTagsWithData', pad).then((newProps: string[]) => { newProps.forEach((prop) => { - tags.push(`span data-${prop[0]}="${prop[1]}"`); + // Attribute names/values here originate from the pad's attribute pool + // (user content), so escape the value and constrain the data-* name + // before interpolating, consistent with the escaping already applied to + // exported URLs and text below. + const dataName = String(prop[0]).replace(/[^a-zA-Z0-9_-]/g, ''); + tags.push(`span data-${dataName}="${Security.escapeHTMLAttribute(String(prop[1]))}"`); props.push(prop); }); }), diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 33240738674..66c63d5ad21 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -1341,6 +1341,24 @@ export const reloadSettings = () => { settings.cookie.prefix = ''; } + // Warn when an account still uses a placeholder/example password from the + // shipped config; these should be changed before the instance is exposed. + // Logged loudly (error level in production) rather than throwing, so test + // fixtures and existing setups that use placeholder credentials still run. + { + const weakPasswords = new Set(['changeme1', 'changeme', 'admin', 'password', '']); + const users = (settings.users || {}) as Record; + const offenders = Object.keys(users).filter((name) => + users[name] && typeof users[name].password === 'string' && + weakPasswords.has(users[name].password as string)); + if (offenders.length) { + const msg = `Account(s) using a default/placeholder password: ${offenders.join(', ')}. ` + + 'Set a strong password (or use the ep_hash_auth plugin) before exposing this instance.'; + if (process.env.NODE_ENV === 'production') logger.error(msg); + else logger.warn(msg); + } + } + if (settings.dbType === 'dirty') { const dirtyWarning = 'DirtyDB is used. This is not recommended for production.'; if (!settings.suppressErrorsInPadText) { From d0742ee7a25c1db7ac19a86b9e8dd3e4014ce9bd Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 7 Jun 2026 15:15:48 +0100 Subject: [PATCH 2/3] Settings: also warn on default OIDC client secrets Extend the placeholder-credential check to cover sso.clients[].client_secret, so a deployment that enables SSO without setting ADMIN_SECRET / USER_SECRET is flagged (error level under NODE_ENV=production) the same way default account passwords are. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/node/utils/Settings.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 66c63d5ad21..4dd34a616ba 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -1357,6 +1357,23 @@ export const reloadSettings = () => { if (process.env.NODE_ENV === 'production') logger.error(msg); else logger.warn(msg); } + + // Same check for OIDC client secrets when SSO is configured: the shipped + // templates fall back to placeholder values if ADMIN_SECRET / USER_SECRET + // are not provided. + const sso = (settings as any).sso; + const ssoClients: Array<{client_id?: string, client_secret?: string}> = + (sso && Array.isArray(sso.clients)) ? sso.clients : []; + const weakSecrets = new Set(['admin', 'user', 'secret', 'changeme', '']); + const secretOffenders = ssoClients + .filter((c) => c && typeof c.client_secret === 'string' && weakSecrets.has(c.client_secret)) + .map((c) => c.client_id || '(unnamed client)'); + if (secretOffenders.length) { + const msg = `SSO client(s) using a default/placeholder client_secret: ${secretOffenders.join(', ')}. ` + + 'Set a strong secret (e.g. via the ADMIN_SECRET / USER_SECRET env vars) before enabling SSO in production.'; + if (process.env.NODE_ENV === 'production') logger.error(msg); + else logger.warn(msg); + } } if (settings.dbType === 'dirty') { From 569b07eb9885353439a346544931a4bc851555d7 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 7 Jun 2026 15:51:40 +0100 Subject: [PATCH 3/3] Move docker-compose changes to a separate PR The deployment-default changes (required credentials, TRUST_PROXY) are being discussed separately; this PR keeps only the non-breaking export escaping and credential-warning changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 653ad9bbeca..90d2f86b591 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,13 +19,11 @@ services: - postgres environment: NODE_ENV: production - # Required — set a strong value (e.g. in .env). No fallback, so misconfig - # surfaces at `docker compose up` rather than at runtime. - ADMIN_PASSWORD: "${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:?Set DOCKER_COMPOSE_APP_ADMIN_PASSWORD to a strong value}" + ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin} DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4} DB_HOST: postgres DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad} - DB_PASS: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}" + DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin} DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432} DB_TYPE: "postgres" DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin} @@ -33,9 +31,7 @@ services: DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- } DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false} SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null} - # Default off: only enable when actually behind a trusted reverse proxy - # that sets the X-Forwarded-* headers. - TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false} + TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true} restart: always ports: - "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}" @@ -44,7 +40,7 @@ services: image: postgres:15-alpine environment: POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad} - POSTGRES_PASSWORD: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}" + POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin} POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432} POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin} PGDATA: /var/lib/postgresql/data/pgdata