Skip to content

Load profile and preview favicons from our route, not from Google #517

Open

Description

@HMarzban

Part of #512. Read the tracker first. Work on main.

Summary

  • Today. The webapp and the server use Google's favicon service, https://www.google.com/s2/favicons. Each icon request sends the reader's IP address and the linked site's origin to Google. The privacy page does not name this.
    • Profile links in another user's profile dialog.
    • Profile links in Settings.
    • The link preview, when the metadata pipeline falls back. The server puts an s2 URL in favicon, and the client loads it.
    • A fourth path stores an s2 URL. sanitizeMetadata copies the fallback's favicon into a saved profile link. Nothing renders that stored value (read from code, not measured).
  • After. Every site icon on these surfaces comes from our own route, GET /api/metadata/icon?origin=, from Add an icon route to link-metadata with an operator-set byte cap #515. No code builds or loads a Google favicon URL. The privacy page says that our server fetches these icons.
  • Why. The pad's new link icons come from our route, and the privacy page will describe them. A page that leaves out these Google calls would be incomplete.

Maintainer rulings that bind this issue

#512 quotes every ruling with its number and date.

  • Ruling 17, file the side issues. This issue is the side issue on the existing Google s2 favicon leaks, adjusted to the new route.
  • Ruling 11, privacy page. This binds this issue too. Step 6 follows the "Link icons" rule in [Tracking] Inline link icons through a hyperlink mark view #512 §Rules every child keeps.
  • Ruling 1, favicons from our own route. The icon route lives in hocuspocus. This issue moves the s2 surfaces onto it.

Depends on / Blocks

Current behaviour

Links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.

  1. getGoogleFaviconUrl builds the s2 URL from the input's origin (link-helpers.ts:17-29). It sends the origin only, not the full link. Its comment relies on s2 "always" answering with a PNG, so there is "no broken-image case". The parse adds https:// only when the input does not start with http. So mailto:a@b.com gives the origin https://b.com, and httpbin.org gives undefined (laptop, plugin only, no view; probe below).
  2. Two callers only. git grep -n getGoogleFaviconUrl finds ProfileLinkRow.tsx and SocialLinks.tsx, plus the definition.
    • The profile dialog calls it for every link that is not email, phone or a known social brand (ProfileLinkRow.tsx:36-55). The <img> has an onError that hides it and shows the LuLink fallback. This dialog shows another user's links, so the viewer's IP goes to Google.
    • Settings links call it through getFaviconUrl (SocialLinks.tsx:79-82). The favicon branch, with the same onError fallback, is SocialLinks.tsx:248-267.
  3. The server preview fallback returns an s2 URL as favicon (fallback.ts:3-4, :26). It is the only code that sets favicon. git grep -n favicon in the module finds no other writer. A unit test pins the s2 URL, and its title names Google (fallback.test.ts:22-27).
  4. Both preview surfaces load favicon in the fourth slot. The order is icon, publisher logo, OG image, favicon, then oembed.thumbnail.
    • Desktop popover: previewShared.ts:109-117. Only when no candidate exists does the special icon branch run, for mailto:, tel: and app schemes (:128-134).
    • Mobile sheet: pickImageSrc(data) takes no href (LinkPreviewSheet.tsx:21-26). A missing image shows a bg-base-300 placeholder. A failed image is hidden, and the 24px slot stays empty (:65-80).
    • So a fallback result sends the reader to Google on each preview of a site the scraper could not read.
  5. Cached fallbacks keep the s2 URL for a while (read from code, not measured):
    • Redis keeps a fallback for 10 minutes (cache.ts:5-6).
    • A cached hit is served with public, max-age=3600, stale-while-revalidate=86400, because its stage reads cache, not fallback (controller.ts:14-15, :57-73).
    • The webapp keeps a 5-minute session cache (fetchMetadata.ts:42-59).
    • So a fix on the server alone would still let old responses reach Google after deploy. The client must never load the favicon value.
  6. Settings stores the s2 URL. sanitizeMetadata sets icon: httpStr(raw.icon) || httpStr(raw.favicon) (link-helpers.ts:53). SocialLinks.tsx saves that metadata in the profile link tree (SocialLinks.tsx:131-144). No webapp code reads metadata.icon (git grep, read from code, not measured).
  7. The REST base URL. See [Tracking] Inline link icons through a hyperlink mark view #512 §Rules every child keeps, on apiBaseUrl.ts and iconRouteUrl.ts.
    • The client base, NEXT_PUBLIC_RESTAPI_URL, ends in /api.
    • The server knows its public origin only as an optional value, config.app.publicUrl from PUBLIC_RESTAPI_URL (env.ts:17). It is an origin with no /api.
  8. The module has no publicUrl today. init takes { redis, logger } (module.ts:12-33, index.ts:157-160). The pipeline calls runFallback(canonical, requestedUrl) (pipeline.ts:80-83). Module rule 3 forbids env reads inside the module (README.md:9). Rule 5 allows only additive wire changes within v1 (README.md:11).
  9. favicon is in the wire contract as an optional string (types.ts:17, OpenAPI components.ts:200). API.md does not name it (API.md:1133).
  10. No Referer reaches the REST host. The webapp sends Referrer-Policy: same-origin on every path, /:path* (next.config.js:353-377). So the <img> elements need no referrerPolicy.
  11. The privacy page has no section on site icons (privacy.tsx:53-60). Both legal pages share LEGAL_UPDATED (legalMetadata.ts:1).

Probe for item 1 and Step 2 (laptop, plugin only, no view). The probe is a local probe, run with bun 1.4.3. It shows the Step 2 rule:

Input getSiteIconUrl result, with NEXT_PUBLIC_RESTAPI_URL=http://localhost:4000/api
https://github.com/x, github.com/x http://localhost:4000/api/metadata/icon?origin=https%3A%2F%2Fgithub.com
http://example.com:8080/a …?origin=http%3A%2F%2Fexample.com%3A8080
httpbin.org …?origin=https%3A%2F%2Fhttpbin.org (today undefined)
mailto:a@b.com, tel:+15551234, sms:+1555 undefined
slack://open?team=T1, zoommtg://zoom.us/join, ftp://x.org undefined
javascript:alert(1), not a url, '' undefined
any input, with the variable unset undefined

Target behaviour

  • No code in apps/, extensions/ or packages/ builds a google.com/s2 URL.
  • Profile links and Settings links show the favicon from our route. A miss (404) or a rate-limit answer (429) shows the existing fallback icon, with no broken image.
  • A preview of a fallback result shows the favicon from our route. The client builds that URL itself and never loads the server's favicon value. Previews of other results keep today's images. mailto:, tel: and app-scheme previews keep their special icon.
  • Settings stores no favicon value in a new profile link.
  • The privacy page says that our server fetches site icons for these surfaces.

Implementation steps

Step 0. Read first

  1. Run git grep -n -e 's2/favicons' -e getGoogleFaviconUrl. Expect hits only in link-helpers.ts, ProfileLinkRow.tsx, SocialLinks.tsx, fallback.ts and fallback.test.ts. If you find another, stop and comment here.
  2. Confirm Add an icon route to link-metadata with an operator-set byte cap #515 is on main. Under make dev-local, run these two probes:
    • curl -i "http://localhost:4000/api/metadata/icon?origin=https://github.com": expect 200 and an image type.
    • curl -i "http://localhost:4000/api/metadata/icon?origin=https://tiptap.dev": expect an empty 404.
  3. Check whether apps/webapp/src/utils/apiBaseUrl.ts exists. Step 1 uses the answer.
  4. Check whether apps/webapp/src/pages/privacy.tsx has a "Link icons" section. Step 6 uses the answer.

Step 1. Shared apiBaseUrl.ts and iconRouteUrl.ts

Follow #512 §Rules every child keeps, on apiBaseUrl.ts and iconRouteUrl.ts. If #516 landed first, check that items 1 to 4 already hold, and go on.

  1. If apps/webapp/src/utils/apiBaseUrl.ts does not exist, create it with the body from fetchMetadata.ts:61-65, exported as apiBaseUrl.
  2. Delete the private copies in fetchMetadata.ts and api/users/fetchLinkMetadata.ts, and import @utils/apiBaseUrl in both.
  3. If apps/webapp/src/utils/iconRouteUrl.ts does not exist, create it per [Tracking] Inline link icons through a hyperlink mark view #512. It calls apiBaseUrl() only inside the function.
  4. Add neither file to the @utils barrel.

Step 2. getSiteIconUrl in utils/link-helpers.ts

  1. Replace getGoogleFaviconUrl with this helper. Drop the size parameter.

    import { iconRouteUrl } from './iconRouteUrl'
    
    /**
     * Our REST icon route. A miss is an empty 404, so each <img> needs onError.
     * Credentials are refused because `mailto:a@b.com` parses as user `mailto` at `b.com`.
     */
    export const getSiteIconUrl = (url: string): string | undefined => {
      try {
        const u = new URL(url.includes('://') ? url : `https://${url}`)
        if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.username || u.password) return undefined
        return iconRouteUrl(u.origin)
      } catch {
        return undefined // also covers apiBaseUrl() throwing when the variable is unset
      }
    }
  2. apiBaseUrl() runs only inside the call. So importing link-helpers.ts never throws, in Jest too.

  3. In sanitizeMetadata, change icon: httpStr(raw.icon) || httpStr(raw.favicon) to icon: httpStr(raw.icon). A new Settings link then stores no fallback favicon.

  4. Check the probe table above with a throwaway bun script outside the repo. Do not commit a test, because none is ruled.

Step 3. Profile and Settings links

  1. In ProfileLinkRow.tsx, call getSiteIconUrl(link.url).
  2. In SocialLinks.tsx, make getFaviconUrl return getSiteIconUrl(link.url).
  3. Keep both onError handlers as they are. A 404 and a 429 both fire onerror, because neither body is an image.
  4. Add no referrerPolicy (Current behaviour item 10).

Step 4. Preview chains (Decision 1)

Stop until a Maintainer ruling comment exists on Decision 1. Alternative A: stop and comment here for the revised steps. Alternative B: do Step 4 only; skip Step 5. With the recommended answer:

  1. In previewShared.ts createMetadataContent, replace safeImageSrc(data?.favicon) with (data?.favicon ? getSiteIconUrl(href) : undefined). Keep it in the same slot, before oembed.thumbnail.
    • Only the fallback sets favicon (Current behaviour item 3). So the field's presence marks a fallback, and its value is never loaded.
    • Old cached responses then carry only a marker. They can no longer send the reader to Google.
    • renderMetadataInto builds data with no favicon from mark attrs (previewShared.ts:160-182). This issue does not change that path. Stop link preview metadata writes from reaching every peer #519 Decision 1 may delete it.
    • getSiteIconUrl returns undefined for mailto:, tel: and app schemes. So the special icon branch still runs for them.
  2. In LinkPreviewSheet.tsx, change pickImageSrc(data) to pickImageSrc(data, href), and make the same favicon swap as item 1.
  3. In useLinkMetadata.ts, keep favicon in LinkMetadata and toLinkMetadata (useLinkMetadata.ts:11-19, :30-38). The sheet now reads it as the marker.
  4. Update the comment at fetchMetadata.ts:71-75 from "hostname+favicon" to "hostname, with a favicon marker".

Step 5. Server fallback (Decision 1)

Stop until a Maintainer ruling comment exists on Decision 1. With the recommended answer:

  1. Add publicUrl?: string | null to InitDeps in module.ts, and default it to null in init. It must stay optional, because specCoverage.test.ts:63 calls init({ redis, logger }) and the hocuspocus typecheck covers that file. Pass it through createRouter to ControllerDeps, and from the controller into runPipeline as an optional publicUrl field. Add an icon route to link-metadata with an operator-set byte cap #515 also edits InitDeps (iconMaxBytes), so merge with its change.
  2. In src/index.ts, pass publicUrl: config.app.publicUrl to linkMetadata.init. The module reads no env (rule 3).
  3. In fallback.ts, delete googleFaviconUrl. Add a third parameter, publicUrl: string | null = null. Set favicon to ${publicUrl}/api/metadata/icon?origin=${encodeURIComponent(parsed.origin)} when both parsed and publicUrl exist, else undefined. Keep the path as the literal /api/metadata/icon. Importing the module index from domain/stages/fallback.ts would make an import cycle and bend module rule 1.
  4. Keep favicon?: string in MetadataResponse and in the OpenAPI schema. The type does not change; only its value changes.
  5. In fallback.test.ts, rewrite the test at lines 22-27. Rename it to "emits the icon route URL when publicUrl is set". Pass 'http://localhost:4000', and expect http://localhost:4000/api/metadata/icon?origin=https%3A%2F%2Fexample.com. Add no other test. Prove it: break the URL template, see the test fail, then restore it.

Step 6. Privacy text (Ruling 11)

  1. If Step 0.4 found the "Link icons" section, check it for the Show a link icon before every pad link in the webapp #516 Step 5.3 sentence about Google. It starts "Profile links and link previews load site icons from Google".
    • If that sentence is there, replace it with: "Profile links, Settings links and link previews load site icons the same way."
    • If it is not there, add that same sentence.
  2. If it did not, add a LegalSection titled "Link icons" after "Who we share with", with this text:
    • "When you view profile links or a link preview, your browser asks our server for the linked website's icon. The request carries the site address, not the full link."
    • "Our server then fetches that icon from the site."
    • The log sentence follows Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1. Stop until a Maintainer ruling comment exists there.
      • If the Traefik query is dropped, add: "Our logs keep your IP address and the request path, not the site address."
      • If it is kept, add: "Our logs keep your IP address. For icons that fail to load, our proxy logs also keep the site address."
    • Show a link icon before every pad link in the webapp #516 Step 5 then extends this section, and does not create a second one. Name this at the Review checkpoint, so the maintainer can tell that issue's executor.
  3. Apply the Show a link icon before every pad link in the webapp #516 Decision 2 ruling to LEGAL_UPDATED in legalMetadata.ts. Stop until that Maintainer ruling comment exists.
    • Do this after item 1 or item 2, because each one changes the privacy text.
    • The recommended answer sets the shared date to the ship date, so the terms page shows the same date. The alternative is a separate privacy date constant. If Show a link icon before every pad link in the webapp #516 already added that constant, set it, not LEGAL_UPDATED.

Step 7. CHANGELOGs

End each bullet with a link to this issue, ([#517](https://github.com/docs-plus/docs.plus/issues/517)). Route the prose through the tech-writer skill.

  • apps/webapp/CHANGELOG.md [Unreleased], new ### Security heading: profile links, Settings links and link previews load site icons from the docs.plus server, not from Google. Settings no longer stores a fallback favicon URL. Name link previews only if Step 4 shipped.
  • apps/hocuspocus.server/CHANGELOG.md [Unreleased], the existing ### Security block: the metadata fallback's favicon now points at /api/metadata/icon when PUBLIC_RESTAPI_URL is set, and is absent otherwise. It is no longer a Google URL. Write this bullet only if Step 5 shipped, and match the ruled answer.
  • Add no root CHANGELOG.md line. The release step writes the product notes.

Review checkpoint

Show the touched files and the test output, then stop. Do not run git add, git commit, git push or git stash. Do not create a branch.

Decisions

Owner: the maintainer. The recommended answer comes first.

  1. Preview fallback: let the server set the fallback favicon to our icon route URL, with a module rule 5 exception? Yes. The client uses the field only as a marker.
    • Rule 5 exception: this change is not additive. It changes the value of a typed v1 field.
    • Cost: a self-host without PUBLIC_RESTAPI_URL shows no icon on fallback previews. Today it shows a Google icon. The docs.plus deploy always sets the variable; its workflows refuse to deploy without it (ENV.md:198). The server change threads publicUrl through init, createRouter, the controller, runPipeline and runFallback, for a value the webapp never loads.
    • Alternative A: the server drops the value, and the client adds our icon to every http(s) preview with no other image.
    • Alternative B: ship only the client marker; other API consumers still get a Google URL.
    • Needed by Steps 4 and 5.

Acceptance criteria

  • With the recommended answer to Decision 1, git grep -n -e 'google.com/s2' -e getGoogleFaviconUrl -- apps extensions packages finds nothing. Under Alternative B, the only hits are fallback.ts and fallback.test.ts.
  • apps/webapp/src/utils/apiBaseUrl.ts holds the only apiBaseUrl() function in the webapp, and it is not in the @utils barrel. The local variable in fetchDocument.ts:55-62 stays.
  • getSiteIconUrl gives the results in the probe table, including undefined with the variable unset.
  • Profile links and Settings links load favicons from /api/metadata/icon. A tiptap.dev link shows the fallback icon.
  • A fallback preview shows our favicon, or no image after a miss. The Network panel shows no request to google.com.
  • mailto:, tel: and slack:// previews still show their special icon, on desktop and on the mobile sheet.
  • A Settings link added after this change stores no metadata.icon from favicon.
  • fallback.test.ts passes with the rewritten test, and it fails when the URL template is broken.
  • The privacy page has the "Link icons" text from Step 6, with one "Link icons" section only. The legal date follows the Show a link icon before every pad link in the webapp #516 Decision 2 ruling.
  • Decision 1 has a Maintainer ruling comment.

Verification

git grep -n -e 'google.com/s2' -e getGoogleFaviconUrl -- apps extensions packages
bun run --filter @docs.plus/webapp typecheck
bun run --filter @docs.plus/webapp test
bun run --filter @docs.plus/hocuspocus typecheck
(cd apps/hocuspocus.server && bun test src/modules/link-metadata src/modules/openapi)
bun run lint
bun run format
  • bun run lint runs the no-restricted-imports rules that guard link-metadata module rules 1 and 4.

Browser walk. Use make dev-local. Filter the Network panel on google.com, then on metadata/icon.

  1. Open another user's profile dialog with web links. Include https://tiptap.dev as a miss.
  2. Open Settings › Profile links. Add a new web link, then check its stored metadata in the profile row.
  3. Hover a link whose preview falls back. Try https://httpbin.org/status/500?n=<random>; the random query skips the 10-minute Redis entry. Confirm pipeline_stage: 'fallback' in the server log line. Whether this URL reaches the fallback is UNVERIFIED; pick another if the log shows a hit.
  4. Hover a mailto: link and a slack:// link.
  5. Repeat items 3 and 4 on the mobile sheet.

Run the theme matrix (docsplus, docsplus-dark, docsplus-dark-hc) only on the profile icon fallback in item 1. Run the other legs once. Use desktop Chrome, iOS Safari and Android Chrome.

Docs to update

  • apps/webapp/src/utils/link-helpers.ts: the helper comment (Step 2).
  • apps/webapp/src/components/TipTap/hyperlinkPopovers/fetchMetadata.ts: the comment at lines 71-75 (Step 4.4).
  • apps/hocuspocus.server/API.md line 1133: add one sentence after the success sentence, only with the recommended answer to Decision 1.
    • "A fallback result's favicon, when present, is this server's GET /api/metadata/icon URL for the site's origin."
  • apps/webapp/src/pages/privacy.tsx and legalMetadata.ts (Step 6).
  • Both CHANGELOGs (Step 7).

Out of scope

  • Old profile rows with an s2 URL in metadata.icon. Nothing renders that field (read from code, not measured), so the rows send no request. Leave them. A cleanup needs a separate maintainer ask.
  • The preview's other image sources: icon, the publisher logo, the OG image and the oEmbed thumbnail. They load from the linked site, not from Google.
  • The SocialIcon brand glyphs. They are local SVG.
  • The pad-link paragraph in the "Link icons" section. Show a link icon before every pad link in the webapp #516 owns that section; Step 6 adds or replaces one sentence only, unless this issue ships first.
  • The LEGAL_UPDATED ruling. Show a link icon before every pad link in the webapp #516 Decision 2 owns it; Step 6.3 applies it.

Read first

  • AGENTS.md, mainly §Test Policy, §Simplified English Mandate and §Workflow And Review Expectations.
  • apps/webapp/CLAUDE.md.
  • apps/hocuspocus.server/CLAUDE.md §HTTP Modules, and apps/hocuspocus.server/src/modules/link-metadata/README.md.
  • .cursor/skills/tech-writer/SKILL.md §Simplified English, for the CHANGELOG bullets, the JSDoc and the privacy text.

Activity

  1. added
    EditorTiptap & Prosemirror
    SecuritySecurity, access control, and data exposure
    on Oct 18, 2026
  2. HMarzban commented on Oct 18, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-18

    The maintainer answered these Decisions through a multiple-choice prompt in the agent session. Each answer below is the option the maintainer picked; the quoted label is the option text.

    Recorded on the tracker #512.

  3. modified the milestones: v2.2.0, v2.3.0 on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EditorTiptap & ProsemirrorSecuritySecurity, access control, and data exposureUI

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions