mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-19 09:34:07 +00:00
* 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:
parent
63cae17720
commit
85f9a5f2f5
13 changed files with 841 additions and 7 deletions
|
|
@ -10,6 +10,8 @@ import settings, {getEpVersion} from '../../utils/Settings';
|
|||
import util from 'node:util';
|
||||
const webaccess = require('./webaccess');
|
||||
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
||||
const i18n = require('../i18n');
|
||||
import {renderSocialMeta} from '../../utils/socialMeta';
|
||||
|
||||
import {build, buildSync} from 'esbuild'
|
||||
import {ArgsExpressType} from "../../types/ArgsExpressType";
|
||||
|
|
@ -172,7 +174,10 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
|
|||
})
|
||||
setRouteHandler('/', (req: any, res: any) => {
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings}));
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
|
||||
});
|
||||
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml}));
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -196,12 +201,16 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
|
|||
});
|
||||
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
|
||||
});
|
||||
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
|
||||
req,
|
||||
toolbar,
|
||||
isReadOnly,
|
||||
entrypoint: proxyPath + '/watch/pad?hash=' + hash,
|
||||
settings: settings.getPublicSettings()
|
||||
settings: settings.getPublicSettings(),
|
||||
socialMetaHtml,
|
||||
})
|
||||
res.send(content);
|
||||
})
|
||||
|
|
@ -227,12 +236,16 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
|
|||
});
|
||||
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
|
||||
});
|
||||
const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', {
|
||||
req,
|
||||
toolbar,
|
||||
isReadOnly,
|
||||
entrypoint: proxyPath + '/watch/timeslider?hash=' + hash,
|
||||
settings: settings.getPublicSettings()
|
||||
settings: settings.getPublicSettings(),
|
||||
socialMetaHtml,
|
||||
})
|
||||
res.send(content);
|
||||
})
|
||||
|
|
@ -342,7 +355,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
|
||||
// serve index.html under /
|
||||
args.app.get('/', (req: any, res: any) => {
|
||||
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex}));
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
|
||||
});
|
||||
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -356,12 +372,16 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
isReadOnly
|
||||
});
|
||||
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
|
||||
});
|
||||
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
|
||||
req,
|
||||
toolbar,
|
||||
isReadOnly,
|
||||
entrypoint: "../"+fileNamePad,
|
||||
settings: settings.getPublicSettings()
|
||||
settings: settings.getPublicSettings(),
|
||||
socialMetaHtml,
|
||||
})
|
||||
res.send(content);
|
||||
});
|
||||
|
|
@ -372,11 +392,15 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
toolbar,
|
||||
});
|
||||
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
|
||||
});
|
||||
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
|
||||
req,
|
||||
toolbar,
|
||||
entrypoint: "../../"+fileNameTimeSlider,
|
||||
settings: settings.getPublicSettings()
|
||||
settings: settings.getPublicSettings(),
|
||||
socialMetaHtml,
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
|
|||
const locales = getAllLocales();
|
||||
const localeIndex = generateLocaleIndex(locales);
|
||||
exports.availableLangs = getAvailableLangs(locales);
|
||||
// Exported so server-rendered HTML (e.g. Open Graph meta tags) can look
|
||||
// up translated strings without re-reading the locale files.
|
||||
exports.locales = locales;
|
||||
|
||||
app.get('/locales/:locale', (req:any, res:any) => {
|
||||
// works with /locale/en and /locale/en.json requests
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
182
src/node/utils/socialMeta.ts
Normal file
182
src/node/utils/socialMeta.ts
Normal 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} = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue