Files
cinny/owl/REPORT-002-external-images.md
Gabor Körber 7f12d047f3 🦉 allowing external images as setting
For the config.json (server-level default for new users):

```json
{
"defaultSettings": {
"externalImages": "homeserver"
}
}
```

User's own choice in the UI always overrides the config.json default.
2026-04-15 21:34:03 +02:00

6.9 KiB

OWL Report #002 - External Images: Settings & Scoping

Date: 2026-04-15

The Question

We've already removed the external image block (sanitize.ts + react-custom-html-parser.tsx). But should this be unconditional? Options:

  1. Global toggle (user setting)
  2. Per-room setting
  3. Automatic based on federation status
  4. Homeserver-level default via config.json

What Context Is Available at Render Time

The key rendering call chain is:

RoomTimeline.tsx (has: room object, mx client, settings)
  → getReactCustomHtmlParser(mx, roomId, params)
    → HTMLReactParserOptions (handles img tags)
      → RenderMessageContent (receives pre-built parser options)

At the RoomTimeline level (line 528 of src/app/features/room/RoomTimeline.tsx), we have:

  • Full room object (Room from matrix-js-sdk)
  • mx (MatrixClient) — knows the homeserver domain
  • All user settings via Jotai atoms

Federation Detection

Rooms carry an m.federate flag in their m.room.create state event:

const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const isFederated = createEvent?.getContent()?.['m.federate'] !== false;

Default is true (federated). Local-only rooms explicitly set m.federate: false.

Homeserver Detection

We can tell if a message sender is local:

const senderDomain = event.getSender()?.split(':')[1];
const isLocal = senderDomain === mx.getDomain();

Options Analysis

Option A: Global User Setting

Add allowExternalImages: boolean to settings, default false (or true via config.json for owl).

Pros: Simple, one toggle, works everywhere. Cons: All-or-nothing. A privacy-conscious user can't allow it for trusted rooms only.

Option B: Per-Room Setting (Room State Event)

Store a custom state event like cx.owl.room.settings with { allowExternalImages: true } per room. Room admins control it.

Pros: Fine-grained, admin-controlled. Cons: Requires room admin action. UX overhead. Need UI for room settings. Only works in rooms we control.

Option C: Automatic by Federation Status

If m.federate === false (local-only room) → allow external images. If federated → block.

Pros: Zero configuration, matches the threat model (federated = untrusted external servers can leak IPs via images). Cons: Most rooms default to m.federate: true even on a single homeserver. Users would need to explicitly create non-federated rooms. Doesn't match the real intent — on owl.cx, all rooms are effectively local even if technically federated.

Option D: Homeserver-Level Default via config.json

Add to config.json:

{
  "defaultSettings": {
    "allowExternalImages": "homeserver"
  }
}

Where the value is one of: "always", "never", "homeserver" (only in rooms on this homeserver), "local" (only non-federated rooms).

Pros: Server operator controls the policy. Matches the owl use case perfectly — "we trust our own server." Cons: More logic to implement. Need to define what "homeserver room" means (all members local? room alias local? room ID local?).

Combine a global setting with smart defaults:

  1. Global setting externalImages: "always" | "never" | "homeserver"
    • Default: "never" (safe for general Cinny users)
    • owl config.json sets: "homeserver" or "always"
  2. "homeserver" mode: allow external images when the room's server matches the user's homeserver (check room ID domain: !roomid:owl.cx)
  3. Per-room override (future): room admins can set cx.owl.room.settings state event to override

This gives us:

  • Safe default for upstream/general use
  • owl.cx sets "homeserver" via config.json — external images work in all owl rooms
  • Users can override to "always" or "never" in settings
  • Room-level control can be added later without changing the architecture

Implementation Plan

Settings Layer

src/app/state/settings.ts:

// Add to Settings interface:
externalImages: 'always' | 'never' | 'homeserver';

// Add to defaultSettings:
externalImages: 'never',

config.json (owl deployment):

{
  "defaultSettings": {
    "externalImages": "homeserver"
  }
}

Rendering Layer

src/app/plugins/react-custom-html-parser.tsx: Add allowExternalImages?: boolean to the params type. When false, revert to the original behavior (convert external img to <a> link).

src/app/features/room/RoomTimeline.tsx: Read the externalImages setting, check room context, compute the boolean allowExternalImages, pass it into getReactCustomHtmlParser params.

const [externalImages] = useSetting(settingsAtom, 'externalImages');
const allowExternalImages = useMemo(() => {
  if (externalImages === 'always') return true;
  if (externalImages === 'never') return false;
  // 'homeserver': check if room belongs to our homeserver
  const roomDomain = room.roomId.split(':')[1];
  return roomDomain === mx.getDomain();
}, [externalImages, room.roomId, mx]);

src/app/utils/sanitize.ts: sanitizeCustomHtml needs the flag too, since it runs before the React parser. Either:

  • Pass it as a parameter: sanitizeCustomHtml(html, { allowExternalImages })
  • Or move all image logic to the React parser (simpler — sanitizer just passes img tags through, parser decides what to render)

The second approach is cleaner: the sanitizer allows img tags with any src (already done), and the React parser decides whether to render or convert to link based on the flag.

Settings UI

Add a dropdown in src/app/features/settings/general/General.tsx under the "Media" or "Privacy" section:

External Images: [Always / Homeserver Only / Never]

Files to Touch

File Change
src/app/state/settings.ts Add externalImages to Settings + defaults
src/app/plugins/react-custom-html-parser.tsx Accept + check allowExternalImages param
src/app/features/room/RoomTimeline.tsx Compute flag from setting + room context, pass to parser
src/app/features/settings/general/General.tsx Add UI dropdown
src/app/utils/sanitize.ts Already done — img tags pass through

What About the Sanitizer?

Current state: we already removed the img→link conversion in sanitize.ts. For the settings-based approach, we have two options:

Option 1 (simpler): Leave sanitize.ts as-is (allows all img src through). The React parser handles the allow/block decision. This means the sanitized HTML contains <img src="https://..."> but the React parser may convert it to a link at render time.

Option 2 (stricter): Restore the original sanitize.ts behavior and only bypass it when allowExternalImages is true. This requires passing options to sanitizeCustomHtml, which changes its signature and every call site.

Recommendation: Option 1. The sanitizer's job is preventing XSS, not policy decisions. Let the React parser handle the rendering policy — it already has all the context it needs.