Skip to content

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

Open
JohnMcLear wants to merge 8 commits intoether:developfrom
JohnMcLear:feat/og-metadata-7599
Open

feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599)#7635
JohnMcLear wants to merge 8 commits intoether:developfrom
JohnMcLear:feat/og-metadata-7599

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

@JohnMcLear JohnMcLear commented Apr 30, 2026

Summary

Closes #7599.

Pad URLs shared in chat apps (WhatsApp, Signal, Slack, Discord, iMessage, Mastodon, etc.) previously unfurled with no preview. This change emits a full set of OG + Twitter Card meta tags on /p/:pad, /p/:pad/timeslider, and /.

Tags emitted: og:type, og:site_name, og:title, og:description, og:url, og:image, og:image:alt, og:locale, plus the twitter:card, twitter:title, twitter:description, twitter:image, twitter:image:alt equivalents.

i18n

The description text is sourced from Etherpad's locale catalog under the new key pad.social.description (default in src/locales/en.json). Other locales fall back to English. Operators override per-language via the existing customLocaleStrings mechanism — no new settings.json key. The i18n hook now exports locales so server-side renderers can read translations.

Accessibility

  • og:locale reflects the language negotiated via req.acceptsLanguages().
  • og:image:alt / twitter:image:alt provide screen-reader text for chat-client preview cards.

Security

Pad names from the URL are user-controlled. The helper HTML-escapes them before interpolating into og:title. Express already URL-decodes :pad route params, so the helper does not call decodeURIComponent again — which would have thrown URIError on names containing literal % (e.g. /p/100%25). Regression test included.

Out of scope

  • A padSocialMetadata plugin hook (YAGNI).
  • Pulling description from the pad body.
  • Generated preview images.

Design doc

docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md

Semver

patch — additive (new tags + new locale string), no settings or behavior surface change.

Test plan

  • CI: socialMeta.ts covers default English description from i18n, locale fallback, URL-decoded pad name, literal % regression, XSS escape, timeslider (history) marker, homepage variant.
  • Manual: load a pad URL in WhatsApp / Signal / Slack and verify the preview unfurls.
  • Manual: set customLocaleStrings.de["pad.social.description"] to a German string, request /p/Foo with Accept-Language: de, confirm the German description appears in og:description.

🤖 Generated with Claude Code

JohnMcLear and others added 3 commits April 30, 2026 05:47
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>
…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>
Closes ether#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>
@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 30, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (2) 📎 Requirement gaps (1)

Context used

Grey Divider


Action required

1. og:description ignores settings.json 📎 Requirement gap ≡ Correctness ⭐ New
Description
The new OG/Twitter metadata implementation sources og:description from the i18n locale catalog
(pad.social.description) instead of a configurable default in settings.json as required. This
means operators cannot set the default description via a dedicated settings value, contrary to the
compliance success criteria.
Code

src/node/utils/socialMeta.ts[R9-45]

+ * 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 '';
+};
Evidence
PR Compliance ID 1 requires og:description to default to a configurable value from
settings.json. The new helper explicitly resolves the description via the i18n locales map
(pad.social.description) and does not read a settings-provided description value; the settings
template changes add publicURL but no description setting.

Provide Open Graph metadata tags for pad pages
src/node/utils/socialMeta.ts[9-45]
settings.json.template[111-125]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`og:description` is currently derived from i18n (`pad.social.description`) rather than defaulting to a configurable value from `settings.json` as required.

## Issue Context
Compliance requires the pad pages’ Open Graph description to be configurable via `settings.json` (not only via locale catalogs/customLocaleStrings).

## Fix Focus Areas
- src/node/utils/socialMeta.ts[9-45]
- src/node/utils/socialMeta.ts[142-167]
- src/node/utils/Settings.ts[160-338]
- settings.json.template[111-125]
- settings.json.docker[120-127]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Broken regex in tests 🐞 Bug ≡ Correctness ⭐ New
Description
The new ogTag() helper in the added backend tests contains an invalid RegExp literal with an
unterminated character class, which will cause a SyntaxError when the file is loaded and prevent the
tests from running. This occurs in both the unit and integration test files, so CI will fail as soon
as it tries to execute the test suite.
Code

src/tests/backend/specs/socialMeta-unit.ts[R11-15]

+const ogTag = (html: string, prop: string): string | null => {
+  const re = new RegExp(
+      `<meta\\s+(?:property|name)="${prop.replace(/[.*+?^${}()|[\\]/g, '\\$&')}"\\s+content="([^"]*)">`);
+  const m = html.match(re);
+  return m ? m[1] : null;
Evidence
The regex literal /[.*+?^${}()|[\\]/g has an opening [ that is never closed, so
JavaScript/TypeScript parsing fails before any tests can run. The same faulty helper was duplicated
into both new test files.

src/tests/backend/specs/socialMeta-unit.ts[8-16]
src/tests/backend/specs/socialMeta.ts[5-14]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new test helper `ogTag()` uses an invalid regex literal: `/[.*+?^${}()|[\\]/g`. The character class is unterminated, which triggers a `SyntaxError` when the test file is parsed, preventing CI/tests from running.

## Issue Context
This appears twice (unit + integration tests) and is intended to escape a string for insertion into a RegExp.

## Fix Focus Areas
- src/tests/backend/specs/socialMeta-unit.ts[11-15]
- src/tests/backend/specs/socialMeta.ts[9-13]

## Expected change
Replace the invalid escape-regex with a correct one, for example:
- `prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` (common canonical form: `/[.*+?^${}()|[\]\\]/g`)

A standard correct pattern is:
```ts
prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
```
(Equivalent common spelling: `/[.*+?^${}()|[\]\\]/g` i.e., `|[\]\\` inside the class.)

Then ensure both test files use the same fixed helper.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Host header poisons OG URLs🐞 Bug ⛨ Security
Description
buildAbsoluteUrl() constructs og:url and og:image using req.protocol and req.get('host'), so a
forged Host/X-Forwarded-* header can make emitted metadata point at an attacker-controlled origin.
This enables misleading unfurl previews and can contribute to cache poisoning if any intermediary
caches HTML by path only.
Code

src/node/utils/socialMeta.ts[R95-99]

+const buildAbsoluteUrl = (req: any, pathname: string): string => {
+  const proto = req.protocol || 'http';
+  const host = (req.get && req.get('host')) || 'localhost';
+  return `${proto}://${host}${pathname}`;
+};
Evidence
The helper builds absolute URLs directly from request headers and injects them into HTML via an
unescaped EJS insertion point, so any poisoning of Host/protocol affects the emitted OG/Twitter
tags.

src/node/utils/socialMeta.ts[95-104]
src/node/utils/socialMeta.ts[132-140]
src/templates/pad.html[14-16]
src/node/hooks/express.ts[157-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`renderSocialMeta()` currently builds absolute URLs using `req.protocol` and `req.get('host')`, which are derived from client-controlled headers (and, with `trust proxy`, from `X-Forwarded-*`). This can cause OG/Twitter tags to advertise attacker-chosen origins.
### Issue Context
The affected values are `og:url`, `og:image`, and their Twitter equivalents, which are inserted into templates via `<%- socialMetaHtml %>`.
### Fix Focus Areas
- src/node/utils/socialMeta.ts[95-104]
- src/node/utils/socialMeta.ts[132-140]
### Suggested fix
- Prefer a configured canonical external origin (e.g., a single setting such as `settings.externalUrl`/`settings.baseUrl`) when generating absolute URLs; fall back to request-derived origin only if not configured.
- If falling back to request-derived values, validate/normalize the host (e.g., strict hostname/host:port parsing) and consider rejecting/ignoring unexpected values.
- Keep `og:image`/`twitter:image` and `og:url` consistent (same origin).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Default socialDescription mismatched📎 Requirement gap ≡ Correctness
Description
The shipped default socialDescription string does not match the required default text, so pad
pages will not emit the mandated og:description value out of the box. This breaks the compliance
success criteria for OG metadata defaults and configurability.
Code

src/node/utils/Settings.ts[R328-334]

+  /**
+   * Description used for Open Graph / Twitter Card link previews when an
+   * Etherpad URL is shared in chat apps. May be a single string applied to
+   * every locale, or an object keyed by BCP-47 language tag with an optional
+   * `default` fallback.
+   */
+  socialDescription: 'A collaborative document that everyone can edit in real time.',
Evidence
PR Compliance ID 1 requires og:description to default to the exact string `A document that
everybody can edit at the same time. and be configurable via settings.json`. The PR sets a
different default string in Settings and the example settings files.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/Settings.ts[328-334]
settings.json.template[111-126]
settings.json.docker[120-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The default `socialDescription` value does not match the compliance-required default string.
## Issue Context
Compliance requires the default `og:description` text to be exactly `A document that everybody can edit at the same time.` while still being configurable via `settings.json`.
## Fix Focus Areas
- src/node/utils/Settings.ts[328-334]
- settings.json.template[111-126]
- settings.json.docker[120-126]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. OG metadata lacks feature flag 📘 Rule violation ⚙ Maintainability
Description
The new social metadata is always rendered on /, /p/:pad, and /p/:pad/timeslider with no
enable/disable mechanism. This violates the requirement that new features be behind a feature flag
and disabled by default.
Code

src/node/hooks/express/specialpages.ts[R358-361]

+      const socialMetaHtml = renderSocialMeta({
+        req, settings, availableLangs: i18n.availableLangs, kind: 'home',
+      });
+      res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
Evidence
PR Compliance ID 5 requires new features to be gated by a flag and disabled by default. The routes
always compute socialMetaHtml and the templates always inject it when provided, with no flag check
or default-off behavior.

src/node/hooks/express/specialpages.ts[358-361]
src/templates/index.html[10-11]
src/templates/pad.html[14-15]
src/templates/timeslider.html[10-11]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social preview metadata is always enabled; there is no feature flag and it is not disabled by default.
## Issue Context
Compliance requires new features to be behind an enable/disable mechanism and disabled by default, with old behavior preserved when disabled.
## Fix Focus Areas
- src/node/hooks/express/specialpages.ts[174-180]
- src/node/hooks/express/specialpages.ts[203-214]
- src/node/hooks/express/specialpages.ts[238-249]
- src/node/hooks/express/specialpages.ts[355-361]
- src/node/hooks/express/specialpages.ts[372-385]
- src/node/hooks/express/specialpages.ts[392-404]
- src/templates/index.html[10-11]
- src/templates/pad.html[14-15]
- src/templates/timeslider.html[10-11]
- src/node/utils/Settings.ts[164-167]
- src/node/utils/Settings.ts[323-335]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. decodeURIComponent(o.padName) may throw📎 Requirement gap ☼ Reliability
Description
renderSocialMeta() calls decodeURIComponent(o.padName) on the Express route param, which can
throw for pad names that decode to strings containing % (e.g., /p/100%25). This can break
pad/timeslider page responses, preventing OG tags from being emitted for some valid pad IDs.
Code

src/node/utils/socialMeta.ts[R125-129]

+  if (o.kind === 'pad' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} | ${siteName}`;
+  } else if (o.kind === 'timeslider' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} (history) | ${siteName}`;
+  }
Evidence
PR Compliance ID 1 requires pad page responses to include OG metadata; if decodeURIComponent()
throws during render, the response can fail and OG tags will not be present. The new code decodes a
value that is already decoded by Express routing, increasing the risk of URIError for certain pad
names.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/socialMeta.ts[125-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`decodeURIComponent(o.padName)` can throw for some pad names (for example those that contain `%` after Express has already decoded the route param).
## Issue Context
This logic runs on the request path for `/p/:pad` and `/p/:pad/timeslider`. A thrown exception can prevent the response from rendering OG tags (and potentially the page).
## Fix Focus Areas
- src/node/utils/socialMeta.ts[123-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Protocol-specific URLs used 📘 Rule violation ≡ Correctness
Description
The OG URL and image URL are constructed with an explicit http:// or https:// prefix instead of
protocol-independent URLs (//...). This may violate the project's requirement to prefer
protocol-independent URLs where applicable.
Code

src/node/utils/socialMeta.ts[R93-106]

+const buildAbsoluteUrl = (req: any, pathname: string): string => {
+  // Honors X-Forwarded-Proto/Host when Express `trust proxy` is set, which is
+  // already the case in production Etherpad deployments behind a reverse proxy.
+  const proto = req.protocol || 'http';
+  const host = (req.get && req.get('host')) || 'localhost';
+  return `${proto}://${host}${pathname}`;
+};
+
+const resolveImageUrl = (req: any, faviconSetting: string | null | undefined): string => {
+  if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
+  // Etherpad serves a favicon at /favicon.ico via the favicon middleware
+  // regardless of whether a custom one is configured.
+  return buildAbsoluteUrl(req, '/favicon.ico');
+};
Evidence
PR Compliance ID 9 requires protocol-independent URLs (//) where applicable. The helper always
emits protocol-specific URLs via ${proto}://${host} and only treats http(s):// as an absolute
settings.favicon, rather than allowing protocol-relative URLs.

src/node/utils/socialMeta.ts[93-106]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social meta helper builds URLs with an explicit protocol instead of protocol-independent URLs.
## Issue Context
Compliance asks to use `//...` where applicable to avoid protocol-specific issues.
## Fix Focus Areas
- src/node/utils/socialMeta.ts[93-106]
- src/node/utils/socialMeta.ts[135-137]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. XSS test allows false pass🐞 Bug ☼ Reliability
Description
The XSS escape test only asserts that og:title (if present) lacks a raw `` tag, so it can pass when
og:title is missing entirely (for example due to a 500 response), masking regressions in social meta
rendering.
Code

src/tests/backend/specs/socialMeta.ts[R86-99]

+    it('HTML-escapes pad names to prevent XSS via crafted IDs', async function () {
+      const res = await agent.get('/p/' + encodeURIComponent('<script>alert(1)</script>'))
+          .expect((r: any) => {
+            // Etherpad may 404 or render — either is fine, but no raw <script>
+            // injected via og:title.
+          });
+      // Whatever the status code, the response body must not contain a raw
+      // <script> from our meta tags.
+      const ogTitle = ogTag(res.text || '', 'og:title');
+      if (ogTitle != null) {
+        assert.ok(!/<script>/i.test(ogTitle),
+            `og:title leaked raw HTML: ${ogTitle}`);
+      }
+    });
Evidence
The test accepts any HTTP status (it uses .expect(fn) without a status assertion) and it
conditionally checks the content only if ogTitle != null, so missing og:title does not fail the
test.

src/tests/backend/specs/socialMeta.ts[86-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The XSS-focused test can pass even if the meta tags are not emitted (e.g., the endpoint errors and returns no `<meta property="og:title">`). This reduces the test’s ability to catch regressions in social meta rendering.
### Issue Context
The test currently:
- Does not assert a status code.
- Does not assert that `og:title` exists.
### Fix Focus Areas
- src/tests/backend/specs/socialMeta.ts[86-99]
### Suggested fix
- Ensure the request hits a reliably-rendering path and assert that `og:title` is present:
- Use a known-good pad ID and expect `200`.
- Assert `ogTag(res.text, 'og:title')` is non-null and contains the escaped form (e.g., `&lt;script&gt;...`).
- Optionally add a separate test that uses a pad ID containing `%25` to prevent regressions related to URL decoding/URIError crashes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

9. og:locale spec mismatch 🐞 Bug ⚙ Maintainability
Description
The design doc in this PR specifies og:locale normalization to xx_XX with fallback to en_US, but
toOgLocale() emits bare language codes (e.g. en) when no region is negotiated. This inconsistency
is encoded in unit tests and can cause future confusion about intended behavior.
Code

src/node/utils/socialMeta.ts[R47-54]

+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();
+};
Evidence
The design document states one format/fallback, while the implementation and tests explicitly assert
another (bare language code).

docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md[31-45]
src/node/utils/socialMeta.ts[47-54]
src/tests/backend/specs/socialMeta-unit.ts[66-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The design doc and the implementation/tests disagree on the intended `og:locale` format for languages without an explicit region.
### Issue Context
- Doc says `xx_XX` and fallback to `en_US`.
- Code returns just `xx` when no region is present.
- Unit test asserts the current behavior.
### Fix Focus Areas
- docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md[43-43]
- src/node/utils/socialMeta.ts[47-54]
- src/tests/backend/specs/socialMeta-unit.ts[66-80]
### Suggested fix
Choose one:
1) Update implementation to map bare `en` → `en_US` (and define a clear mapping strategy for other bare languages), then update tests.
2) Update the design doc to reflect the current implementation (bare language codes allowed).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 62b86c7

Results up to commit 0f5295a


🐞 Bugs (2) 📘 Rule violations (2) 📎 Requirement gaps (0)

Context used

Action required
1. Host header poisons OG URLs 🐞 Bug ⛨ Security ⭐ New
Description
buildAbsoluteUrl() constructs og:url and og:image using req.protocol and req.get('host'), so a
forged Host/X-Forwarded-* header can make emitted metadata point at an attacker-controlled origin.
This enables misleading unfurl previews and can contribute to cache poisoning if any intermediary
caches HTML by path only.
Code

src/node/utils/socialMeta.ts[R95-99]

+const buildAbsoluteUrl = (req: any, pathname: string): string => {
+  const proto = req.protocol || 'http';
+  const host = (req.get && req.get('host')) || 'localhost';
+  return `${proto}://${host}${pathname}`;
+};
Evidence
The helper builds absolute URLs directly from request headers and injects them into HTML via an
unescaped EJS insertion point, so any poisoning of Host/protocol affects the emitted OG/Twitter
tags.

src/node/utils/socialMeta.ts[95-104]
src/node/utils/socialMeta.ts[132-140]
src/templates/pad.html[14-16]
src/node/hooks/express.ts[157-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`renderSocialMeta()` currently builds absolute URLs using `req.protocol` and `req.get('host')`, which are derived from client-controlled headers (and, with `trust proxy`, from `X-Forwarded-*`). This can cause OG/Twitter tags to advertise attacker-chosen origins.

### Issue Context
The affected values are `og:url`, `og:image`, and their Twitter equivalents, which are inserted into templates via `<%- socialMetaHtml %>`.

### Fix Focus Areas
- src/node/utils/socialMeta.ts[95-104]
- src/node/utils/socialMeta.ts[132-140]

### Suggested fix
- Prefer a configured canonical external origin (e.g., a single setting such as `settings.externalUrl`/`settings.baseUrl`) when generating absolute URLs; fall back to request-derived origin only if not configured.
- If falling back to request-derived values, validate/normalize the host (e.g., strict hostname/host:port parsing) and consider rejecting/ignoring unexpected values.
- Keep `og:image`/`twitter:image` and `og:url` consistent (same origin).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Default socialDescription mismatched📎 Requirement gap ≡ Correctness
Description
The shipped default socialDescription string does not match the required default text, so pad
pages will not emit the mandated og:description value out of the box. This breaks the compliance
success criteria for OG metadata defaults and configurability.
Code

src/node/utils/Settings.ts[R328-334]

+  /**
+   * Description used for Open Graph / Twitter Card link previews when an
+   * Etherpad URL is shared in chat apps. May be a single string applied to
+   * every locale, or an object keyed by BCP-47 language tag with an optional
+   * `default` fallback.
+   */
+  socialDescription: 'A collaborative document that everyone can edit in real time.',
Evidence
PR Compliance ID 1 requires og:description to default to the exact string `A document that
everybody can edit at the same time. and be configurable via settings.json`. The PR sets a
different default string in Settings and the example settings files.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/Settings.ts[328-334]
settings.json.template[111-126]
settings.json.docker[120-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The default `socialDescription` value does not match the compliance-required default string.
## Issue Context
Compliance requires the default `og:description` text to be exactly `A document that everybody can edit at the same time.` while still being configurable via `settings.json`.
## Fix Focus Areas
- src/node/utils/Settings.ts[328-334]
- settings.json.template[111-126]
- settings.json.docker[120-126]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. OG metadata lacks feature flag 📘 Rule violation ⚙ Maintainability
Description
The new social metadata is always rendered on /, /p/:pad, and /p/:pad/timeslider with no
enable/disable mechanism. This violates the requirement that new features be behind a feature flag
and disabled by default.
Code

src/node/hooks/express/specialpages.ts[R358-361]

+      const socialMetaHtml = renderSocialMeta({
+        req, settings, availableLangs: i18n.availableLangs, kind: 'home',
+      });
+      res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
Evidence
PR Compliance ID 5 requires new features to be gated by a flag and disabled by default. The routes
always compute socialMetaHtml and the templates always inject it when provided, with no flag check
or default-off behavior.

src/node/hooks/express/specialpages.ts[358-361]
src/templates/index.html[10-11]
src/templates/pad.html[14-15]
src/templates/timeslider.html[10-11]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social preview metadata is always enabled; there is no feature flag and it is not disabled by default.
## Issue Context
Compliance requires new features to be behind an enable/disable mechanism and disabled by default, with old behavior preserved when disabled.
## Fix Focus Areas
- src/node/hooks/express/specialpages.ts[174-180]
- src/node/hooks/express/specialpages.ts[203-214]
- src/node/hooks/express/specialpages.ts[238-249]
- src/node/hooks/express/specialpages.ts[355-361]
- src/node/hooks/express/specialpages.ts[372-385]
- src/node/hooks/express/specialpages.ts[392-404]
- src/templates/index.html[10-11]
- src/templates/pad.html[14-15]
- src/templates/timeslider.html[10-11]
- src/node/utils/Settings.ts[164-167]
- src/node/utils/Settings.ts[323-335]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. decodeURIComponent(o.padName) may throw📎 Requirement gap ☼ Reliability
Description
renderSocialMeta() calls decodeURIComponent(o.padName) on the Express route param, which can
throw for pad names that decode to strings containing % (e.g., /p/100%25). This can break
pad/timeslider page responses, preventing OG tags from being emitted for some valid pad IDs.
Code

src/node/utils/socialMeta.ts[R125-129]

+  if (o.kind === 'pad' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} | ${siteName}`;
+  } else if (o.kind === 'timeslider' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} (history) | ${siteName}`;
+  }
Evidence
PR Compliance ID 1 requires pad page responses to include OG metadata; if decodeURIComponent()
throws during render, the response can fail and OG tags will not be present. The new code decodes a
value that is already decoded by Express routing, increasing the risk of URIError for certain pad
names.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/socialMeta.ts[125-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`decodeURIComponent(o.padName)` can throw for some pad names (for example those that contain `%` after Express has already decoded the route param).
## Issue Context
This logic runs on the request path for `/p/:pad` and `/p/:pad/timeslider`. A thrown exception can prevent the response from rendering OG tags (and potentially the page).
## Fix Focus Areas
- src/node/utils/socialMeta.ts[123-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
5. Protocol-specific URLs used 📘 Rule violation ≡ Correctness
Description
The OG URL and image URL are constructed with an explicit http:// or https:// prefix instead of
protocol-independent URLs (//...). This may violate the project's requirement to prefer
protocol-independent URLs where applicable.
Code

src/node/utils/socialMeta.ts[R93-106]

+const buildAbsoluteUrl = (req: any, pathname: string): string => {
+  // Honors X-Forwarded-Proto/Host when Express `trust proxy` is set, which is
+  // already the case in production Etherpad deployments behind a reverse proxy.
+  const proto = req.protocol || 'http';
+  const host = (req.get && req.get('host')) || 'localhost';
+  return `${proto}://${host}${pathname}`;
+};
+
+const resolveImageUrl = (req: any, faviconSetting: string | null | undefined): string => {
+  if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
+  // Etherpad serves a favicon at /favicon.ico via the favicon middleware
+  // regardless of whether a custom one is configured.
+  return buildAbsoluteUrl(req, '/favicon.ico');
+};
Evidence
PR Compliance ID 9 requires protocol-independent URLs (//) where applicable. The helper always
emits protocol-specific URLs via ${proto}://${host} and only treats http(s):// as an absolute
settings.favicon, rather than allowing protocol-relative URLs.

src/node/utils/socialMeta.ts[93-106]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social meta helper builds URLs with an explicit protocol instead of protocol-independent URLs.
## Issue Context
Compliance asks to use `//...` where applicable to avoid protocol-specific issues.
## Fix Focus Areas
- src/node/utils/socialMeta.ts[93-106]
- src/node/utils/socialMeta.ts[135-137]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. XSS test allows false pass🐞 Bug ☼ Reliability
Description
The XSS escape test only asserts that og:title (if present) lacks a raw `` tag, so it can pass when
og:title is missing entirely (for example due to a 500 response), masking regressions in social meta
rendering.
Code

src/tests/backend/specs/socialMeta.ts[R86-99]

+    it('HTML-escapes pad names to prevent XSS via crafted IDs', async function () {
+      const res = await agent.get('/p/' + encodeURIComponent('<script>alert(1)</script>'))
+          .expect((r: any) => {
+            // Etherpad may 404 or render — either is fine, but no raw <script>
+            // injected via og:title.
+          });
+      // Whatever the status code, the response body must not contain a raw
+      // <script> from our meta tags.
+      const ogTitle = ogTag(res.text || '', 'og:title');
+      if (ogTitle != null) {
+        assert.ok(!/<script>/i.test(ogTitle),
+            `og:title leaked raw HTML: ${ogTitle}`);
+      }
+    });
Evidence
The test accepts any HTTP status (it uses .expect(fn) without a status assertion) and it
conditionally checks the content only if ogTitle != null, so missing og:title does not fail the
test.

src/tests/backend/specs/socialMeta.ts[86-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The XSS-focused test can pass even if the meta tags are not emitted (e.g., the endpoint errors and returns no `<meta property="og:title">`). This reduces the test’s ability to catch regressions in social meta rendering.
### Issue Context
The test currently:
- Does not assert a status code.
- Does not assert that `og:title` exists.
### Fix Focus Areas
- src/tests/backend/specs/socialMeta.ts[86-99]
### Suggested fix
- Ensure the request hits a reliably-rendering path and assert that `og:title` is present:
- Use a known-good pad ID and expect `200`.
- Assert `ogTag(res.text, 'og:title')` is non-null and contains the escaped form (e.g., `&lt;script&gt;...`).
- Optionally add a separate test that uses a pad ID containing `%25` to prevent regressions related to URL decoding/URIError crashes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments
7. og:locale spec mismatch 🐞 Bug ⚙ Maintainability ⭐ New
Description
The design doc in this PR specifies og:locale normalization to xx_XX with fallback to en_US, but
toOgLocale() emits bare language codes (e.g. en) when no region is negotiated. This inconsistency
is encoded in unit tests and can cause future confusion about intended behavior.
Code

src/node/utils/socialMeta.ts[R47-54]

+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();
+};
Evidence
The design document states one format/fallback, while the implementation and tests explicitly assert
another (bare language code).

docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md[31-45]
src/node/utils/socialMeta.ts[47-54]
src/tests/backend/specs/socialMeta-unit.ts[66-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The design doc and the implementation/tests disagree on the intended `og:locale` format for languages without an explicit region.

### Issue Context
- Doc says `xx_XX` and fallback to `en_US`.
- Code returns just `xx` when no region is present.
- Unit test asserts the current behavior.

### Fix Focus Areas
- docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md[43-43]
- src/node/utils/socialMeta.ts[47-54]
- src/tests/backend/specs/socialMeta-unit.ts[66-80]

### Suggested fix
Choose one:
1) Update implementation to map bare `en` → `en_US` (and define a clear mapping strategy for other bare languages), then update tests.
2) Update the design doc to reflect the current implementation (bare language codes allowed).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit N/A


🐞 Bugs (0) 📘 Rule violations (6) 📎 Requirement gaps (0)


Action required
1. Default socialDescription mismatched📎 Requirement gap ≡ Correctness
Description
The shipped default socialDescription string does not match the required default text, so pad
pages will not emit the mandated og:description value out of the box. This breaks the compliance
success criteria for OG metadata defaults and configurability.
Code

src/node/utils/Settings.ts[R328-334]

+  /**
+   * Description used for Open Graph / Twitter Card link previews when an
+   * Etherpad URL is shared in chat apps. May be a single string applied to
+   * every locale, or an object keyed by BCP-47 language tag with an optional
+   * `default` fallback.
+   */
+  socialDescription: 'A collaborative document that everyone can edit in real time.',
Evidence
PR Compliance ID 1 requires og:description to default to the exact string `A document that
everybody can edit at the same time. and be configurable via settings.json`. The PR sets a
different default string in Settings and the example settings files.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/Settings.ts[328-334]
settings.json.template[111-126]
settings.json.docker[120-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The default `socialDescription` value does not match the compliance-required default string.
## Issue Context
Compliance requires the default `og:description` text to be exactly `A document that everybody can edit at the same time.` while still being configurable via `settings.json`.
## Fix Focus Areas
- src/node/utils/Settings.ts[328-334]
- settings.json.template[111-126]
- settings.json.docker[120-126]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. OG metadata lacks feature flag 📘 Rule violation ⚙ Maintainability
Description
The new social metadata is always rendered on /, /p/:pad, and /p/:pad/timeslider with no
enable/disable mechanism. This violates the requirement that new features be behind a feature flag
and disabled by default.
Code

src/node/hooks/express/specialpages.ts[R358-361]

+      const socialMetaHtml = renderSocialMeta({
+        req, settings, availableLangs: i18n.availableLangs, kind: 'home',
+      });
+      res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
Evidence
PR Compliance ID 5 requires new features to be gated by a flag and disabled by default. The routes
always compute socialMetaHtml and the templates always inject it when provided, with no flag check
or default-off behavior.

src/node/hooks/express/specialpages.ts[358-361]
src/templates/index.html[10-11]
src/templates/pad.html[14-15]
src/templates/timeslider.html[10-11]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social preview metadata is always enabled; there is no feature flag and it is not disabled by default.
## Issue Context
Compliance requires new features to be behind an enable/disable mechanism and disabled by default, with old behavior preserved when disabled.
## Fix Focus Areas
- src/node/hooks/express/specialpages.ts[174-180]
- src/node/hooks/express/specialpages.ts[203-214]
- src/node/hooks/express/specialpages.ts[238-249]
- src/node/hooks/express/specialpages.ts[355-361]
- src/node/hooks/express/specialpages.ts[372-385]
- src/node/hooks/express/specialpages.ts[392-404]
- src/templates/index.html[10-11]
- src/templates/pad.html[14-15]
- src/templates/timeslider.html[10-11]
- src/node/utils/Settings.ts[164-167]
- src/node/utils/Settings.ts[323-335]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. decodeURIComponent(o.padName) may throw📎 Requirement gap ☼ Reliability
Description
renderSocialMeta() calls decodeURIComponent(o.padName) on the Express route param, which can
throw for pad names that decode to strings containing % (e.g., /p/100%25). This can break
pad/timeslider page responses, preventing OG tags from being emitted for some valid pad IDs.
Code

src/node/utils/socialMeta.ts[R125-129]

+  if (o.kind === 'pad' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} | ${siteName}`;
+  } else if (o.kind === 'timeslider' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} (history) | ${siteName}`;
+  }
Evidence
PR Compliance ID 1 requires pad page responses to include OG metadata; if decodeURIComponent()
throws during render, the response can fail and OG tags will not be present. The new code decodes a
value that is already decoded by Express routing, increasing the risk of URIError for certain pad
names.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/socialMeta.ts[125-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`decodeURIComponent(o.padName)` can throw for some pad names (for example those that contain `%` after Express has already decoded the route param).
## Issue Context
This logic runs on the request path for `/p/:pad` and `/p/:pad/timeslider`. A thrown exception can prevent the response from rendering OG tags (and potentially the page).
## Fix Focus Areas
- src/node/utils/socialMeta.ts[123-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (6)
4. Default socialDescription mismatched📎 Requirement gap ≡ Correctness
Description
The shipped default socialDescription string does not match the required default text, so pad
pages will not emit the mandated og:description value out of the box. This breaks the compliance
success criteria for OG metadata defaults and configurability.
Code

src/node/utils/Settings.ts[R328-334]

+  /**
+   * Description used for Open Graph / Twitter Card link previews when an
+   * Etherpad URL is shared in chat apps. May be a single string applied to
+   * every locale, or an object keyed by BCP-47 language tag with an optional
+   * `default` fallback.
+   */
+  socialDescription: 'A collaborative document that everyone can edit in real time.',
Evidence
PR Compliance ID 1 requires og:description to default to the exact string `A document that
everybody can edit at the same time. and be configurable via settings.json`. The PR sets a
different default string in Settings and the example settings files.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/Settings.ts[328-334]
settings.json.template[111-126]
settings.json.docker[120-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The default `socialDescription` value does not match the compliance-required default string.
## Issue Context
Compliance requires the default `og:description` text to be exactly `A document that everybody can edit at the same time.` while still being configurable via `settings.json`.
## Fix Focus Areas
- src/node/utils/Settings.ts[328-334]
- settings.json.template[111-126]
- settings.json.docker[120-126]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. OG metadata lacks feature flag 📘 Rule violation ⚙ Maintainability
Description
The new social metadata is always rendered on /, /p/:pad, and /p/:pad/timeslider with no
enable/disable mechanism. This violates the requirement that new features be behind a feature flag
and disabled by default.
Code

src/node/hooks/express/specialpages.ts[R358-361]

+      const socialMetaHtml = renderSocialMeta({
+        req, settings, availableLangs: i18n.availableLangs, kind: 'home',
+      });
+      res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
Evidence
PR Compliance ID 5 requires new features to be gated by a flag and disabled by default. The routes
always compute socialMetaHtml and the templates always inject it when provided, with no flag check
or default-off behavior.

src/node/hooks/express/specialpages.ts[358-361]
src/templates/index.html[10-11]
src/templates/pad.html[14-15]
[src/templates/timeslider.html[10-11]](https://github.co...

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Add Open Graph and Twitter Card metadata for social media link previews

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Emit Open Graph and Twitter Card meta tags for pad, timeslider, and homepage URLs
• Support configurable per-locale descriptions via new socialDescription setting
• HTML-escape pad names in meta tags to prevent reflected XSS attacks
• Negotiate language from request headers for og:locale and description resolution
Diagram
flowchart LR
  A["Request to /p/:pad<br/>or /timeslider<br/>or /"] -->|renderSocialMeta| B["Resolve language<br/>& description"]
  B -->|escapeHtml| C["Build meta tags<br/>og:title, og:description<br/>twitter:card, etc."]
  C -->|inject into template| D["HTML response<br/>with OG/Twitter meta"]
  E["settings.socialDescription<br/>string or lang map"] -.->|resolveDescription| B
Loading

Grey Divider

File Changes

1. src/node/hooks/express/specialpages.ts ✨ Enhancement +30/-6

Inject social meta tags into route handlers

src/node/hooks/express/specialpages.ts


2. src/node/utils/socialMeta.ts ✨ Enhancement +144/-0

New helper to build OG and Twitter Card meta tags

src/node/utils/socialMeta.ts


3. src/node/utils/Settings.ts ⚙️ Configuration changes +9/-0

Add socialDescription setting with locale support

src/node/utils/Settings.ts


View more (7)
4. src/tests/backend/specs/socialMeta.ts 🧪 Tests +122/-0

Comprehensive test suite for social meta functionality

src/tests/backend/specs/socialMeta.ts


5. src/templates/index.html ✨ Enhancement +1/-0

Inject social meta tags into homepage template

src/templates/index.html


6. src/templates/pad.html ✨ Enhancement +1/-0

Inject social meta tags into pad template

src/templates/pad.html


7. src/templates/timeslider.html ✨ Enhancement +1/-0

Inject social meta tags into timeslider template

src/templates/timeslider.html


8. settings.json.template 📝 Documentation +16/-0

Document new socialDescription configuration option

settings.json.template


9. settings.json.docker ⚙️ Configuration changes +7/-0

Add socialDescription to Docker settings template

settings.json.docker


10. docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md 📝 Documentation +163/-0

Design specification for Open Graph metadata feature

docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 30, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (2) 📎 Requirement gaps (2)

Context used

Grey Divider


Action required

1. Default socialDescription mismatched 📎 Requirement gap ≡ Correctness
Description
The shipped default socialDescription string does not match the required default text, so pad
pages will not emit the mandated og:description value out of the box. This breaks the compliance
success criteria for OG metadata defaults and configurability.
Code

src/node/utils/Settings.ts[R328-334]

+  /**
+   * Description used for Open Graph / Twitter Card link previews when an
+   * Etherpad URL is shared in chat apps. May be a single string applied to
+   * every locale, or an object keyed by BCP-47 language tag with an optional
+   * `default` fallback.
+   */
+  socialDescription: 'A collaborative document that everyone can edit in real time.',
Evidence
PR Compliance ID 1 requires og:description to default to the exact string `A document that
everybody can edit at the same time. and be configurable via settings.json`. The PR sets a
different default string in Settings and the example settings files.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/Settings.ts[328-334]
settings.json.template[111-126]
settings.json.docker[120-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The default `socialDescription` value does not match the compliance-required default string.

## Issue Context
Compliance requires the default `og:description` text to be exactly `A document that everybody can edit at the same time.` while still being configurable via `settings.json`.

## Fix Focus Areas
- src/node/utils/Settings.ts[328-334]
- settings.json.template[111-126]
- settings.json.docker[120-126]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. OG metadata lacks feature flag 📘 Rule violation ⚙ Maintainability
Description
The new social metadata is always rendered on /, /p/:pad, and /p/:pad/timeslider with no
enable/disable mechanism. This violates the requirement that new features be behind a feature flag
and disabled by default.
Code

src/node/hooks/express/specialpages.ts[R358-361]

+      const socialMetaHtml = renderSocialMeta({
+        req, settings, availableLangs: i18n.availableLangs, kind: 'home',
+      });
+      res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
Evidence
PR Compliance ID 5 requires new features to be gated by a flag and disabled by default. The routes
always compute socialMetaHtml and the templates always inject it when provided, with no flag check
or default-off behavior.

src/node/hooks/express/specialpages.ts[358-361]
src/templates/index.html[10-11]
src/templates/pad.html[14-15]
src/templates/timeslider.html[10-11]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social preview metadata is always enabled; there is no feature flag and it is not disabled by default.

## Issue Context
Compliance requires new features to be behind an enable/disable mechanism and disabled by default, with old behavior preserved when disabled.

## Fix Focus Areas
- src/node/hooks/express/specialpages.ts[174-180]
- src/node/hooks/express/specialpages.ts[203-214]
- src/node/hooks/express/specialpages.ts[238-249]
- src/node/hooks/express/specialpages.ts[355-361]
- src/node/hooks/express/specialpages.ts[372-385]
- src/node/hooks/express/specialpages.ts[392-404]
- src/templates/index.html[10-11]
- src/templates/pad.html[14-15]
- src/templates/timeslider.html[10-11]
- src/node/utils/Settings.ts[164-167]
- src/node/utils/Settings.ts[323-335]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. decodeURIComponent(o.padName) may throw 📎 Requirement gap ☼ Reliability
Description
renderSocialMeta() calls decodeURIComponent(o.padName) on the Express route param, which can
throw for pad names that decode to strings containing % (e.g., /p/100%25). This can break
pad/timeslider page responses, preventing OG tags from being emitted for some valid pad IDs.
Code

src/node/utils/socialMeta.ts[R125-129]

+  if (o.kind === 'pad' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} | ${siteName}`;
+  } else if (o.kind === 'timeslider' && o.padName) {
+    title = `${decodeURIComponent(o.padName)} (history) | ${siteName}`;
+  }
Evidence
PR Compliance ID 1 requires pad page responses to include OG metadata; if decodeURIComponent()
throws during render, the response can fail and OG tags will not be present. The new code decodes a
value that is already decoded by Express routing, increasing the risk of URIError for certain pad
names.

Add Open Graph metadata tags for pad sharing previews
src/node/utils/socialMeta.ts[125-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`decodeURIComponent(o.padName)` can throw for some pad names (for example those that contain `%` after Express has already decoded the route param).

## Issue Context
This logic runs on the request path for `/p/:pad` and `/p/:pad/timeslider`. A thrown exception can prevent the response from rendering OG tags (and potentially the page).

## Fix Focus Areas
- src/node/utils/socialMeta.ts[123-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Protocol-specific URLs used 📘 Rule violation ≡ Correctness
Description
The OG URL and image URL are constructed with an explicit http:// or https:// prefix instead of
protocol-independent URLs (//...). This may violate the project's requirement to prefer
protocol-independent URLs where applicable.
Code

src/node/utils/socialMeta.ts[R93-106]

+const buildAbsoluteUrl = (req: any, pathname: string): string => {
+  // Honors X-Forwarded-Proto/Host when Express `trust proxy` is set, which is
+  // already the case in production Etherpad deployments behind a reverse proxy.
+  const proto = req.protocol || 'http';
+  const host = (req.get && req.get('host')) || 'localhost';
+  return `${proto}://${host}${pathname}`;
+};
+
+const resolveImageUrl = (req: any, faviconSetting: string | null | undefined): string => {
+  if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
+  // Etherpad serves a favicon at /favicon.ico via the favicon middleware
+  // regardless of whether a custom one is configured.
+  return buildAbsoluteUrl(req, '/favicon.ico');
+};
Evidence
PR Compliance ID 9 requires protocol-independent URLs (//) where applicable. The helper always
emits protocol-specific URLs via ${proto}://${host} and only treats http(s):// as an absolute
settings.favicon, rather than allowing protocol-relative URLs.

src/node/utils/socialMeta.ts[93-106]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Social meta helper builds URLs with an explicit protocol instead of protocol-independent URLs.

## Issue Context
Compliance asks to use `//...` where applicable to avoid protocol-specific issues.

## Fix Focus Areas
- src/node/utils/socialMeta.ts[93-106]
- src/node/utils/socialMeta.ts[135-137]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. XSS test allows false pass 🐞 Bug ☼ Reliability
Description
The XSS escape test only asserts that og:title (if present) lacks a raw <script> tag, so it can
pass when og:title is missing entirely (for example due to a 500 response), masking regressions in
social meta rendering.
Code

src/tests/backend/specs/socialMeta.ts[R86-99]

+    it('HTML-escapes pad names to prevent XSS via crafted IDs', async function () {
+      const res = await agent.get('/p/' + encodeURIComponent('<script>alert(1)</script>'))
+          .expect((r: any) => {
+            // Etherpad may 404 or render — either is fine, but no raw <script>
+            // injected via og:title.
+          });
+      // Whatever the status code, the response body must not contain a raw
+      // <script> from our meta tags.
+      const ogTitle = ogTag(res.text || '', 'og:title');
+      if (ogTitle != null) {
+        assert.ok(!/<script>/i.test(ogTitle),
+            `og:title leaked raw HTML: ${ogTitle}`);
+      }
+    });
Evidence
The test accepts any HTTP status (it uses .expect(fn) without a status assertion) and it
conditionally checks the content only if ogTitle != null, so missing og:title does not fail the
test.

src/tests/backend/specs/socialMeta.ts[86-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The XSS-focused test can pass even if the meta tags are not emitted (e.g., the endpoint errors and returns no `<meta property="og:title">`). This reduces the test’s ability to catch regressions in social meta rendering.

### Issue Context
The test currently:
- Does not assert a status code.
- Does not assert that `og:title` exists.

### Fix Focus Areas
- src/tests/backend/specs/socialMeta.ts[86-99]

### Suggested fix
- Ensure the request hits a reliably-rendering path and assert that `og:title` is present:
 - Use a known-good pad ID and expect `200`.
 - Assert `ogTag(res.text, 'og:title')` is non-null and contains the escaped form (e.g., `&lt;script&gt;...`).
- Optionally add a separate test that uses a pad ID containing `%25` to prevent regressions related to URL decoding/URIError crashes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

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>
Comment thread src/node/utils/Settings.ts Outdated
Comment thread src/node/hooks/express/specialpages.ts
Comment thread src/node/utils/socialMeta.ts Outdated
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 30, 2026

Persistent review updated to latest commit cf5e926

JohnMcLear and others added 2 commits April 30, 2026 06:29
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 ether#7635.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ings 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 ether#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>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 30, 2026

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

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>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 30, 2026

Persistent review updated to latest commit 0f5295a

Comment thread src/node/utils/socialMeta.ts Outdated
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 ether#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>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 30, 2026

Persistent review updated to latest commit 62b86c7

Comment on lines +9 to +45
* 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 '';
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. og:description ignores settings.json 📎 Requirement gap ≡ Correctness

The new OG/Twitter metadata implementation sources og:description from the i18n locale catalog
(pad.social.description) instead of a configurable default in settings.json as required. This
means operators cannot set the default description via a dedicated settings value, contrary to the
compliance success criteria.
Agent Prompt
## Issue description
`og:description` is currently derived from i18n (`pad.social.description`) rather than defaulting to a configurable value from `settings.json` as required.

## Issue Context
Compliance requires the pad pages’ Open Graph description to be configurable via `settings.json` (not only via locale catalogs/customLocaleStrings).

## Fix Focus Areas
- src/node/utils/socialMeta.ts[9-45]
- src/node/utils/socialMeta.ts[142-167]
- src/node/utils/Settings.ts[160-338]
- settings.json.template[111-125]
- settings.json.docker[120-127]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +11 to +15
const ogTag = (html: string, prop: string): string | null => {
const re = new RegExp(
`<meta\\s+(?:property|name)="${prop.replace(/[.*+?^${}()|[\\]/g, '\\$&')}"\\s+content="([^"]*)">`);
const m = html.match(re);
return m ? m[1] : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Broken regex in tests 🐞 Bug ≡ Correctness

The new ogTag() helper in the added backend tests contains an invalid RegExp literal with an
unterminated character class, which will cause a SyntaxError when the file is loaded and prevent the
tests from running. This occurs in both the unit and integration test files, so CI will fail as soon
as it tries to execute the test suite.
Agent Prompt
## Issue description
The new test helper `ogTag()` uses an invalid regex literal: `/[.*+?^${}()|[\\]/g`. The character class is unterminated, which triggers a `SyntaxError` when the test file is parsed, preventing CI/tests from running.

## Issue Context
This appears twice (unit + integration tests) and is intended to escape a string for insertion into a RegExp.

## Fix Focus Areas
- src/tests/backend/specs/socialMeta-unit.ts[11-15]
- src/tests/backend/specs/socialMeta.ts[9-13]

## Expected change
Replace the invalid escape-regex with a correct one, for example:
- `prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` (common canonical form: `/[.*+?^${}()|[\]\\]/g`)

A standard correct pattern is:
```ts
prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
```
(Equivalent common spelling: `/[.*+?^${}()|[\]\\]/g` i.e., `|[\]\\` inside the class.)

Then ensure both test files use the same fixed helper.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

};

export type RenderOpts = {
req: any,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we type this better?

Copy link
Copy Markdown
Member

@SamTV12345 SamTV12345 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some any's in this pr. Besides that nice addition :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Open Graph Metadata

2 participants