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.
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:
- Global toggle (user setting)
- Per-room setting
- Automatic based on federation status
- 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
roomobject (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?).
Option E: Layered Approach (Recommended)
Combine a global setting with smart defaults:
- Global setting
externalImages:"always"|"never"|"homeserver"- Default:
"never"(safe for general Cinny users) - owl config.json sets:
"homeserver"or"always"
- Default:
- "homeserver" mode: allow external images when the room's server matches the user's homeserver (check room ID domain:
!roomid:owl.cx) - Per-room override (future): room admins can set
cx.owl.room.settingsstate 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.