feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599) (#7635)

* docs(spec): Open Graph metadata for pad pages (issue #7599)

Spec for adding og:* and twitter:card meta tags to /p/:pad,
the timeslider, and the homepage so shared links unfurl with
a useful preview in chat apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(spec): expand OG spec — i18n (locale map + og:locale) and a11y (image:alt)

Address review feedback: socialDescription accepts a per-language map,
og:locale is emitted from the negotiated render language, and image:alt
attributes are emitted for screen readers in chat clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: emit Open Graph & Twitter Card metadata for pad/timeslider/home

Closes #7599.

Pad URLs shared in chat apps (WhatsApp, Signal, Slack, etc.) previously
unfurled with no preview because the rendered HTML carried no OG or
Twitter Card metadata. This change emits og:title, og:description,
og:image, og:url, og:site_name, og:type, og:locale, og:image:alt and
the equivalent twitter:* tags on the pad page, the timeslider, and the
homepage.

A new settings.json key `socialDescription` controls the description.
It accepts either a plain string applied to every locale or a per-language
map keyed by BCP-47 tag with an optional `default` fallback. og:locale
is emitted from the language already negotiated via req.acceptsLanguages
and og:image:alt provides screen-reader text for chat-client previews.

Pad names from the URL are HTML-escaped before being interpolated into
og:title to prevent reflected XSS via crafted pad IDs.

Tests: src/tests/backend/specs/socialMeta.ts covers the default,
per-locale override, locale fallback, URL decoding, XSS escape, and
the timeslider/homepage variants.

Semver: minor (new setting; templates emit additional tags but no
existing behavior changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): use valid pad-name char in URL-decode test

Spaces aren't allowed in pad names — Etherpad redirected /p/Has%20Space*
to a sanitized name (302), so the og:title assertion failed. Use %2D
("-") instead, which is a valid pad-name character and still exercises
the URL-decode path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(socialMeta): don't double-decode pad name from req.params.pad

Express has already URL-decoded :pad route params before they reach the
handler. Calling decodeURIComponent on the result throws URIError for
pad names containing a literal "%" — e.g. the URL /p/100%25 yields
req.params.pad === "100%", and decodeURIComponent("100%") throws.

This would have prevented the page from rendering for some valid pad
IDs. Drop the redundant decode and add a regression test for the "%"
case.

Reported by Qodo on PR #7635.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(socialMeta): source description from i18n catalog, drop settings key

Per review: the OG description is a translatable string and belongs in
Etherpad's locale files alongside the rest of the UI strings, not in
settings.json. Operators who want to override it per-language continue to
use the standard customLocaleStrings mechanism — no new config surface.

Changes:
- Add "pad.social.description" to src/locales/en.json (default English).
- Export i18n.locales so server-side renderers can look up translations.
- socialMeta.renderSocialMeta now takes a `locales` map and resolves
  renderLang → primary subtag → en, instead of taking a per-locale map
  from settings.
- Remove `socialDescription` from Settings.ts, settings.json.template,
  settings.json.docker (the key never shipped).
- Update tests and spec doc to reflect i18n-sourced description.

Reported by Qodo on PR #7635 (also confirmed feature is fine to land
default-on; no flag needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(socialMeta): add unit tests for pure helpers

21 cases exercising buildSocialMetaHtml and renderSocialMeta directly,
without HTTP/DB. Covers tag enumeration, HTML escaping, og:locale
region formatting, title composition (pad/timeslider/home), description
i18n resolution (exact/primary/en fallback, missing catalog), image URL
(default favicon vs absolute settings.favicon vs alt text), canonical
URL building with query-string stripping, the literal "%" no-throw
regression, and attribute-breakout escape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(socialMeta): defend og:url/og:image against host-header poisoning

Previously og:url and og:image were built from req.protocol +
req.get('host'), both of which can be client-controlled (Host header
directly, or X-Forwarded-* under trust proxy). A crafted Host could
make the server emit OG tags pointing at an attacker's origin —
harmful if any cache fronts the response or if a vulnerable proxy
forwards the headers unsanitized.

Two-layer defense:

1. New optional setting `publicURL` lets operators pin the canonical
   origin used for shared link previews ("https://pad.example"). When
   set, og:url and og:image use it unconditionally. Sanitized at use
   time: must be http(s)://host[:port] with no path, no userinfo, no
   trailing slash; malformed values fall back to the request.

2. When `publicURL` is unset, the request-derived fallback now strictly
   validates the Host header against /^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?(:\d{1,5})?$/i
   and caps the scheme to "http"/"https". A crafted Host (CRLF
   injection, userinfo, "<script>") is replaced with "localhost"
   instead of being echoed into og:url.

Reported by Qodo on PR #7635.

Tests: 5 new unit cases covering publicURL preference, trailing-slash
strip, malformed-publicURL fallback, Host validation, scheme cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(socialMeta): tighten types, drop `any`

- `req: any` -> express `Request` (covers acceptsLanguages/protocol/get/originalUrl).
- `settings: any` -> local `SocialMetaSettings` interface narrowed to the three
  fields we actually read (title/favicon/publicURL); avoids coupling to the
  full Settings module surface.
- `availableLangs: {[k: string]: any}` -> `{[lang: string]: unknown}`; only
  keys are read, so values stay deliberately unconstrained.

No runtime change. All 26 socialMeta unit tests still pass.

Per Sam's review on #7635.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-01 17:43:29 +08:00 committed by GitHub
parent 63cae17720
commit 85f9a5f2f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 841 additions and 7 deletions

View file

@ -164,6 +164,7 @@ export type SettingsType = {
title: string,
showRecentPads: boolean,
favicon: string | null,
publicURL: string | null,
ttl: {
AccessToken: number,
AuthorizationCode: number,
@ -323,6 +324,18 @@ const settings: SettingsType = {
* Etherpad root directory.
*/
favicon: null,
/**
* Canonical public origin of this Etherpad instance, e.g. "https://pad.example.com".
* When set, it is used to build absolute URLs in server-rendered output (currently
* the Open Graph / Twitter Card meta tags). When null, those URLs fall back to the
* incoming request's protocol+host, which is safe when Host/X-Forwarded-Host
* headers are trusted but should be configured explicitly in production to avoid
* client-controlled origin values appearing in og:url / og:image.
*
* No trailing slash. Must include scheme.
*/
publicURL: null,
ttl: {
AccessToken: 1 * 60 * 60, // 1 hour in seconds
AuthorizationCode: 10 * 60, // 10 minutes in seconds

View file

@ -0,0 +1,182 @@
'use strict';
import type {Request} from 'express';
/**
* Builds the Open Graph + Twitter Card <meta> tag block for the pad page,
* timeslider and homepage. Output values are HTML-escaped pad names are
* user-controlled, so this is the security boundary that prevents reflected
* XSS via crafted pad IDs.
*
* The description text is sourced from Etherpad's i18n catalog under the key
* `pad.social.description`. Operators can override it per-language via the
* standard `customLocaleStrings` mechanism in settings.json.
*/
const SOCIAL_DESCRIPTION_KEY = 'pad.social.description';
const ESCAPE_MAP: {[ch: string]: string} = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
const resolveDescription = (
locales: {[lang: string]: {[key: string]: string}} | undefined,
renderLang: string,
): string => {
if (!locales) return '';
// Exact match.
if (locales[renderLang] && locales[renderLang][SOCIAL_DESCRIPTION_KEY]) {
return locales[renderLang][SOCIAL_DESCRIPTION_KEY];
}
// Primary subtag fallback (e.g. de-AT → de).
const primary = renderLang.split('-')[0];
if (locales[primary] && locales[primary][SOCIAL_DESCRIPTION_KEY]) {
return locales[primary][SOCIAL_DESCRIPTION_KEY];
}
// English fallback.
if (locales.en && locales.en[SOCIAL_DESCRIPTION_KEY]) {
return locales.en[SOCIAL_DESCRIPTION_KEY];
}
return '';
};
const toOgLocale = (renderLang: string): string => {
// Open Graph wants `xx_XX`. We already negotiate render language from
// request headers; if it has a region we keep it (lowercased primary,
// uppercased region), otherwise we just emit the primary subtag.
const parts = renderLang.split('-');
if (parts.length >= 2) return `${parts[0].toLowerCase()}_${parts[1].toUpperCase()}`;
return parts[0].toLowerCase();
};
export type SocialMetaOpts = {
url: string,
siteName: string,
title: string,
description: string,
imageUrl: string,
imageAlt: string,
renderLang: string,
};
export const buildSocialMetaHtml = (opts: SocialMetaOpts): string => {
const tag = (prop: string, value: string, attr: 'property' | 'name' = 'property') =>
` <meta ${attr}="${prop}" content="${escapeHtml(value)}">`;
return [
tag('og:type', 'website'),
tag('og:site_name', opts.siteName),
tag('og:title', opts.title),
tag('og:description', opts.description),
tag('og:url', opts.url),
tag('og:image', opts.imageUrl),
tag('og:image:alt', opts.imageAlt),
tag('og:locale', toOgLocale(opts.renderLang)),
tag('twitter:card', 'summary', 'name'),
tag('twitter:title', opts.title, 'name'),
tag('twitter:description', opts.description, 'name'),
tag('twitter:image', opts.imageUrl, 'name'),
tag('twitter:image:alt', opts.imageAlt, 'name'),
].join('\n');
};
// Only the keys are read; values are intentionally unconstrained because the
// i18n module hands us a record whose value shape varies by language.
type AvailableLangs = {[lang: string]: unknown};
// Narrow shape of the global Settings module that this file actually touches.
// Defined locally to avoid coupling socialMeta to the full Settings surface.
type SocialMetaSettings = {
title?: string,
favicon?: string | null,
publicURL?: string | null,
};
const negotiateRenderLang = (req: Request, availableLangs: AvailableLangs): string => {
if (req && typeof req.acceptsLanguages === 'function') {
const negotiated = req.acceptsLanguages(Object.keys(availableLangs));
if (negotiated) return negotiated;
}
return 'en';
};
// Strict hostname[:port] pattern. Rejects header injection (\r\n), userinfo
// (user@host), wildcards, and any non-DNS-character garbage. Length-capped so
// a giant Host header can't blow up the response.
const HOST_RE = /^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?(:\d{1,5})?$/i;
const sanitizeHost = (host: string | undefined): string | null => {
if (!host || host.length > 255) return null;
return HOST_RE.test(host) ? host : null;
};
const sanitizePublicURL = (raw: string | null | undefined): string | null => {
if (!raw || typeof raw !== 'string') return null;
// Must be http(s)://host[:port], no path. Strip trailing slash if present.
const m = raw.replace(/\/+$/, '').match(/^(https?):\/\/([^\/?#]+)$/i);
if (!m) return null;
return sanitizeHost(m[2]) ? `${m[1].toLowerCase()}://${m[2]}` : null;
};
// Builds an absolute URL. Prefers settings.publicURL when configured (operator-
// trusted); otherwise falls back to the request's protocol+Host with strict
// host validation so a crafted Host header can't appear in og:url / og:image.
const buildAbsoluteUrl = (
req: Request, pathname: string, publicURL: string | null | undefined,
): string => {
const trusted = sanitizePublicURL(publicURL);
if (trusted) return `${trusted}${pathname}`;
const proto = req.protocol === 'https' ? 'https' : 'http';
const host = sanitizeHost(req.get && req.get('host')) || 'localhost';
return `${proto}://${host}${pathname}`;
};
const resolveImageUrl = (
req: Request, faviconSetting: string | null | undefined, publicURL: string | null | undefined,
): string => {
if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
return buildAbsoluteUrl(req, '/favicon.ico', publicURL);
};
export type RenderOpts = {
req: Request,
settings: SocialMetaSettings,
availableLangs: AvailableLangs,
locales: {[lang: string]: {[key: string]: string}},
kind: 'pad' | 'timeslider' | 'home',
padName?: string,
};
export const renderSocialMeta = (o: RenderOpts): string => {
const renderLang = negotiateRenderLang(o.req, o.availableLangs);
const siteName = o.settings.title || 'Etherpad';
const description = resolveDescription(o.locales, renderLang);
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL);
const imageAlt = `${siteName} logo`;
let title = siteName;
let pathname = (o.req && o.req.originalUrl) || '/';
if (o.padName) {
// Express has already URL-decoded :pad route params; do not decode again.
if (o.kind === 'pad') title = `${o.padName} | ${siteName}`;
else if (o.kind === 'timeslider') title = `${o.padName} (history) | ${siteName}`;
}
const qIdx = pathname.indexOf('?');
if (qIdx >= 0) pathname = pathname.slice(0, qIdx);
return buildSocialMetaHtml({
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL),
siteName,
title,
description,
imageUrl,
imageAlt,
renderLang,
});
};