Skip to content

API key management returns 403 for Tailscale/CGNAT (100.64.0.0/10) clients behind a reverse proxy when authentication is disabled #655

@s3ntin3l8

Description

@s3ntin3l8

Suggested label: bug

Summary

When authentication is disabled (the default), the API-key management endpoints are gated to "local or private-network" callers. That private-network check (SecurityRequestUtils.IsPrivateOrLoopback) only accepts RFC1918 IPv4 (10/8, 172.16/12, 192.168/16) + loopback/link-local. It does not include the CGNAT range 100.64.0.0/10 that Tailscale assigns to nodes, nor does it accept global IPv6. As a result, a client reaching Listenarr through a (trusted) reverse proxy from a Tailscale IP is treated as a public/untrusted caller and receives 403 on GET/POST /api/v{ver}/configuration/apikey*.

In the UI this surfaces as an empty API Key field (Settings → General): copy is greyed out and the regenerate button does nothing, because the frontend silently swallows the 403.

Environment

  • Listenarr: 1.0.7 (official Docker image)
  • Host: unraid, Docker; .NET 10.0.8 (Linux x64)
  • Reverse proxy: Traefik edge (TLS terminates at the proxy, forwards plain HTTP + X-Forwarded-For)
  • Access path: browser → Traefik (192.168.2.96:443) → Listenarr (192.168.2.179:4546)
  • Client network: Tailscale — client source IP 100.83.13.2 (in 100.64.0.0/10)
  • Auth: disabled (default)
  • Browser-independent (the gate is server-side / IP-based).

Steps to reproduce

  1. Run Listenarr with authentication disabled (default).
  2. Put it behind a reverse proxy on a trusted RFC1918 network (so app.UseForwardedHeaders() honors the proxy's X-Forwarded-For).
  3. Access the UI from a client whose forwarded source IP is in 100.64.0.0/10 (e.g. any Tailscale node), and open Settings → General.

Minimal unit-level reproduction (no proxy needed):

SecurityRequestUtils.IsPrivateOrLoopback(IPAddress.Parse("100.83.13.2")); // returns false

Expected behavior

The API Key field should never silently render blank with a greyed copy button and a no-op regenerate button. When the security gate blocks the request, the UI should clearly explain why the key is hidden and how to view it — rather than appearing broken.

Actual behavior

  • GET /api/v1/configuration/apikey403
    {"message":"API key management is only available from local or private-network clients when authentication is disabled"}
  • API Key field renders empty; copy button greyed (:disabled="!apiKey"); regenerate is a no-op.
  • Every other part of the app works for the same client — only the API-key endpoints are IP-gated, which is why just this field breaks.

Root cause

  • listenarr.application/Security/SecurityRequestUtils.cs:124-156IsPrivateOrLoopback IPv4 branch lacks 100.64.0.0/10; IPv6 branch accepts only loopback/link-local/site-local/fc00::/7 (all global IPv6 is "non-private").
  • listenarr.api/Attributes/RequireApiKeyManagementAccessAttribute.cs:51-84 — with auth disabled, the filter returns 403 unless IsLocalOrPrivateRequest is true.
  • listenarr.api/Program.cs:271-283,700UseForwardedHeaders trusts RFC1918/fc00::/7/fe80::/10 proxies with the default ForwardLimit (1); the single peeled X-Forwarded-For entry (the Tailscale client IP) is what the gate evaluates.

Resolution

The security gate is intentional and kept as-is — it must not be weakened. The trust-broadening options first floated here (hardcode 100.64.0.0/10; a configurable trusted-networks list; or trusting the forwarded client via an already-trusted proxy) were rejected, because IsLocalOrPrivateRequest also drives ShouldRedactSecretsForCaller: widening "trusted" would hand un-redacted config secrets to those callers across ~11 endpoints, for every deployment — far beyond the API key.

The real defect is the silently-swallowed 403 in the frontend (SettingsView.vuecatch { apiKey.value = '' }), which turned the gate's response into a blank field with no explanation. The accepted fix keeps the gate unchanged and makes the UI explain it: the API Key field's help text now states the key is only revealed to signed-in administrators or local/private-network clients, and tells the user how to view it (enable authentication and sign in, or access from a local/trusted network). (PR to follow.)

Related / follow-ups

  • Contextual error surfacing (optional enhancement): this fix communicates the gate via help text; a richer version would surface a note only when the 403 actually occurs — bind the error in fe/src/views/SettingsView.vue:1393-1398 (it carries .status) and render an inline callout near the field.
  • Regenerate unusable behind any reverse proxy: when the field is blank, onRegenerate calls the loopback-only generateInitialApiKey (listenarr.api/Controllers/Configurations/ApiKeyController.cs:96-104, IPAddress.IsLoopback), which can never succeed for a proxied client (and 409s if a key already exists). Separate follow-up: distinguish "load failed" from "no key" and use the management-access path.

Workaround

Enable authentication and sign in as admin (the API-key endpoints then authorize via the admin session instead of the IP check), or read the key directly from config.json (ApiKey).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions