Skip to content

security: validate OIDC avatar URLs and only store real images - #1295

Open
claudiusbirdwhistle wants to merge 8 commits into
dedicatedcode:mainfrom
claudiusbirdwhistle:security/oidc-avatar
Open

claudiusbirdwhistle wants to merge 8 commits into
dedicatedcode:mainfrom
claudiusbirdwhistle:security/oidc-avatar

Conversation

@claudiusbirdwhistle

@claudiusbirdwhistle claudiusbirdwhistle commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #1291, for the URL validator. This branch contains its commits. The new commits are the last two.

Problem

On login, the picture URL supplied by the identity provider is fetched by the server, with redirects followed, and stored as the user's avatar with a content type guessed from the URL. Many identity providers let users set that URL themselves. That gives server-side request forgery, with the response readable afterwards through the avatar endpoint.

Changes

  • URL checks: the URL and every redirect hop are checked by the shared OutboundUrlValidator, and the download is capped at 5 MB.
  • Image-only storage: the avatar is only stored if its bytes are a PNG, JPEG, GIF or WebP image, with the detected type.

Tests

CustomOidcUserServiceTest: the avatar URLs in these tests now use a private-network address instead of localhost, because loopback targets are rejected.


Security fix series (from a review of 5.6.0; most PRs are independent, dependencies are noted in each PR):

GitHub's stacked pull requests are not available for contributions from forks, so dependent PRs include their prerequisite commits and name the new ones. Happy to rebase, split or merge these differently if that is easier to review.

🤖 Generated with Claude Code

User supplied URLs (integrations, map styles, geocoders, federation,
OIDC avatars) are fetched server side. Add OutboundUrlValidator which
only allows http(s), resolves the host and always rejects loopback,
link-local/cloud metadata, unspecified, multicast and embedded IPv4
variants of those, plus the instance's own database, redis and tile
cache. Private networks (RFC 1918 IPv4 ranges, carrier-grade NAT
100.64.0.0/10 and IPv6 unique local addresses fc00::/7) are rejected only when
reitti.security.outbound.allow-private-networks / OUTBOUND_ALLOW_PRIVATE_NETWORKS
is false (default true so LAN integrations keep working).

The shared RestTemplate no longer follows redirects and gets finite
timeouts; OutboundHttp offers a size-capped GET that validates every
redirect hop. ImageTypes detects images by their file signature (the
"magic bytes" at the start of the file) instead of trusting the name.
- Validate style, TileJSON and tile template URLs on save and again
  before every server side fetch (style.json, TileJSON, tiles), validate
  redirect hops and cap response sizes.
- proxyTiles and shared can only be set by admins (checked against the
  stored role), they were only hidden in the UI.
- Tile proxy endpoints fail closed when the style is unknown or not
  accessible; the tile URL caches are checked against the user's access
  and keyed by style version.
- Deleting a style is limited to its owner (admins may delete shared
  styles); default styles cannot be deleted.
- Escape <, >, & and U+2028/9 in the map style JSON that templates
  inline into script blocks (stored XSS via shared style names).
…regression tests

Tests cover admin-only proxyTiles/shared, rejected internal style URLs,
scoped deletes, script-safe inlining of shared style names and the
fail-closed tile proxy.
The tile cache proxies to whatever URL is sent in X-Reitti-Upstream-Url.
It is only reachable on the internal docker network and Reitti
validates user supplied upstreams, but as defense in depth reject
non-http(s) upstreams and loopback, unspecified, link-local (cloud
metadata) and IPv6 literal hosts.
The IdP supplied picture URL was fetched server side and stored as the
user's avatar with a content type guessed from the URL. Now the URL and
every redirect hop are validated (loopback, link-local/metadata, ... are
rejected), the download is capped at 5 MB and the avatar is only stored
if its bytes are a PNG/JPEG/GIF/WebP image, with the detected type.
The shared bean also knows the instance's internal services (database, redis,
tile cache) and honours reitti.security.outbound.allow-private-networks.

This branch has not been deployed

No deployments
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.

1 participant