Skip to content

Proxy headers: send custom headers to a server behind a sign-in proxy - #294

Merged
KodeStar merged 33 commits into
mainfrom
feat/custom-headers
Nov 14, 2026
Merged

KodeStar merged 33 commits into
mainfrom
feat/custom-headers

Conversation

@KodeStar

@KodeStar KodeStar commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Closes #167. For servers behind a sign-in proxy (Authelia, Authentik, Cloudflare Access service tokens and similar), a connection can carry custom HTTP headers that the proxy checks.

What it does

  • Entry: a Proxy headers section on the connect screen (type the address and headers before Continue or a QR scan; pairing keeps them when the address matches) and a Proxy headers card on the Account page (pick the address, add, change, remove; a saved value is never shown again, "Kept as it is"). Strings in all 6 locales.
  • Storage: secure-store per connection (audiosilo.proxy-headers.<id>, the same place and keychain setting as the token; on the web, where the token also lives in localStorage). AsyncStorage holds only a hasProxyHeaders marker. Deleted wherever the token is; the identity migration copies with a read-back check and deletes the old entry only at clean-up.
  • Scope: sent with requests to the https address (scheme+host+port) the set was entered for, which may be the home address; never on a plain-http request, and not added to requests for other addresses. A 3xx to another origin is followed by the platform: on Android every stack sends the headers on to the new origin (drops Authorization); iOS unchecked (follow-up Proxy headers follow-ups: shared sets, scoping edges, proxy 401, device run #295). Reserved names (Authorization, Cookie, Host, Range, Content-Type, X-AudioSilo-Client, framing headers) and control characters are refused.
  • Where: every API request; expo-image, the native players and the reader's EPUB download via authHeaders(); downloads, car and widget covers via proxyHeadersFor(url). On the web only same-origin fetches carry them (<img>/<audio> rely on the proxy's browser sign-in). Not carried: Chromecast (cast refused, reason proxy), the watch apps, calendar apps. No server change.
  • Sharing on one server: a new connection (another person, a link re-pair with no set of its own) keeps the set that carried its successful sign-in or exchange; a reconnect with a known identity keeps only its own or the typed set.

Tests and review

New unit tests for storage, origin scoping, https rule, migration, reconnect/re-pair, hydrate clean-up, editor and card, and a locale test that refuses "nowhere else" style claims. Full gate green (559 suites, 7730 tests). Reviewed at the security level (two full /code-review passes) plus two fix rounds.

Verified live (web, served player over https)

Headers on 31/31 API requests to the origin after relaunch, none on covers or the stream (as documented), none to another origin even with CSP stripped; masked after save and absent from HTML, inputs and console; reserved names and control characters refused; http-only connection shows the https caption and no Add; Remove and sign-out clear storage; sign-in, browse and playback unchanged without headers.

Verified on Android (Pixel 6a, release build, behind a header-checking https proxy)

API (264/264 requests), expo-image covers, Media3 playback and downloads all carried the header and worked; without it the proxy refused and the app showed an error; an http address shows the https caption. A GET 302 to a second https origin: okhttp fetch, expo-image, downloads and Media3 all followed it and sent the custom header there, dropping Authorization.

Still owed (iOS device): URLSession, SDWebImage covers, downloads, AVURLAsset, CarPlay cold start, the widget, and the same redirect check. Android Auto, notification artwork and the reader's EPUB download were not exercised.

Docs: KodeStar/audiosilo-docs#305. Pairs with KodeStar/audiosilo-server#381 (same batch, independent).

People who put their server behind Cloudflare Access, Authentik, Authelia
or a similar auth proxy could not use the apps: the proxy answered every
request with its own login. A connection can now carry custom headers for
that proxy.

They are credentials, so they live where the session token lives
(secure-store, the after-first-unlock keychain service on iOS), never in
the AsyncStorage metadata, a URL or a log line, and go with the
connection on sign-out, a retired identity and the auth reset, and move
with the identity migration. A set names the one origin it was entered
for and only requests to that origin carry it: the home address, a
plain-http fallback or any other address get none, and the web sends them
only same-origin (the server's CORS lists a fixed set of headers).

ApiClient sends them on every request and hands them to the media layers
(expo-image covers, the native player, the reader's EPUB), and downloads,
the car's covers and the widget's covers fetch with them. The connect
flow's bare clients and the home probe use the set held for their
origin. Cast is refused for an address behind the proxy, since a
receiver fetches by itself. No server change.

Part of #167.
A proxy in front of the server blocks GET /server, so the headers have to
be there before the first request: the connect screen gets a Proxy
headers section under the address field, holding them for that address
while the listener continues, signs in or scans a pairing code for it.
Afterwards the Account page's Proxy headers card shows the header names
with their values hidden, and changes or removes them, with a choice of
which of the server's addresses they go to. It is shown whatever the
server's flags, since it is needed most when the proxy refuses
everything.

A value is never shown again: a stored row's field says "Kept as it is"
and keeps the value unless a new one is typed. Reserved names
(Authorization, Cookie, Host and the like) and values with control
characters are refused with a reason under the row. Both places say
what cannot carry the headers (Chromecast, the watch apps, calendar
apps, and in a browser covers, audio and other origins).

Part of #167.
The storage, the one-origin rule, how the connect flow and the clients
carry them, and what cannot, so later work on the clients, the media
layers or the connect flow keeps them scoped.

Part of #167.
The headers followed the address field, so a plain-http fallback, which
rewrites the field, rebound them to the http origin and the sign-in sent
them in the clear. They now follow the address as the listener typed it.

Scanning a pairing code hands it back to a new connect screen, which set
the held headers to none on mount and so paired without them. The new
screen now takes up the headers the flow holds, and they are dropped
when the flow's layout goes rather than when one screen does.

Part of #167.
Every launch read a proxy-headers item per connection, for everyone. On
iOS a missing item falls through to a second keychain lookup, and on a
locked phone (a CarPlay start) that path is untested: a refusal would
fail the hydrate for people who never used the feature. The metadata now
carries a non-secret hasProxyHeaders marker, and only a marked
connection's set is read; a refused read of one still fails the hydrate.

A household member signed in on the owner's device (addMemberHere) got
a connection with no headers, so a server behind a proxy turned the
member away. A new connection now takes the set another connection to
the same server has, saved under its own id and still sent only to its
origin.

Also tests that a pairing code for the headers' address pairs with them
and one for another server without.

Part of #167.
Nothing read it. Its comment pointed at the Cast check, which asks a
different question on purpose (behindProxy blocks Cast on the web too,
where a cross-origin client sends no headers and the getter said
false). Left on the credential-carrying class, it invited a later
caller to pick the wrong predicate and let a proxied connection
through to a Cast device.

Part of #167.
connectClient and probeServerId each spelled out the same tokenless
ApiClient, with the proxy headers as a sixth positional argument after
two undefineds. connectClient now takes the probe's timeout and lives
in src/api, so the probe calls it and a third bare client cannot
forget the headers (or send them somewhere connectProxyHeaders would
not).

Part of #167.
resolveClient (the save loop, the car, the downloads, the widget, cast)
and the provider's buildClients (every screen) each constructed the
same ApiClient by hand: token, dead-token callback, reachability
callback, proxy headers. A change made to one and not the other gives
a connection that works on screen and fails in the background, or the
reverse; with proxy headers that failure is the proxy's HTML refusal
with nothing to say why. Both now call connectionClient.

Part of #167.
The Account card passed each stored value to maskProxyValue to get
eight dots back. The mask is now a constant, so the value does not
reach a display helper at all. Also in the same helpers: the reserved
list takes the client header's name from CLIENT_HEADER (a rename can
no longer leave it unreserved), draftProblems and draftToSet share the
one row-to-header conversion they must agree on, the card's save error
uses FormError like the other Account cards, and sentTo drops an
argument no locale reads.

Part of #167.
The editor's fields were plain Inputs, the only ones in the connect
column that were: with the keyboard already up, moving to a second
header row at the bottom of the column left it underneath. They are
ConnectInputs now (a no-op on the Account card, which has no
ConnectFrame above it). The editor's testID prop, which no caller
passed, is gone; its ids are literals a grep finds.

Part of #167.
Ten copies of the same seven-line setSession call hid the one line
that differs per test.

Part of #167.
finishConnect asked connectProxyHeaders for the address's set, which
answers with the first connection that has one for that origin. With
two people on one server each keeping their own headers, the second
person signing in again (nothing typed on the connect screen) was
handed the first person's set, and setSession stored it over their
own. connectProxyHeaders now takes the id the sign-in is for and
answers with that connection's set before anyone else's.

Part of #167.
Both are images the server serves with the media token in the URL, so
their expo-image sources carried no headers. Behind an auth proxy the
app showed a placeholder for every community cover (series rails,
previous books) and every podcast directory artwork, while book
covers loaded. They now take proxyHeadersFor(url), which checks the
origin: a community cover loaded straight from its own host (a server
without meta_covers) still gets none.

Part of #167.
CONVENTIONS.md gains the two image sources that now carry the headers,
the re-pair rule, and an open item: the origin rule covers the request
the app makes, while a 3xx is followed by the platform's HTTP stack
under its own rule for custom headers, unchecked on a device so far.
CLAUDE.md and secure-store.ts no longer say the secure store holds
only the session token.

Part of #167.
A connection whose set is for its public address, signing in again
through the home address (or a second public one) with nothing typed,
was handed another connection's set for that address. setSession then
replaced its own set and wrote over it in secure-store, so its public
address was refused by the proxy and the secret was gone. Asked for a
named connection, connectProxyHeaders now answers with that
connection's set or with nothing; nothing makes setSession keep what
the connection had.

Part of #167.
removeConnection deleted the token, the calendar link and the proxy
headers together. When the token delete failed (a keychain error) the
removal rejected and the connection stayed, but its headers were
already gone, since forgetting them never rejects. The marker still
said it had some, so after the next launch it hydrated with none and
the proxy refused every request. The token goes first now; a failure
stops the removal with the headers still kept.

Part of #167.
Hydrate drops a connection whose token item is gone (a restore to a
new device, an invalidated keystore entry), and the next write removes
its metadata. Nothing then knew its id, so its proxy headers stayed in
the keychain with no way to remove them from the app. They are
forgotten when the connection is dropped.

Part of #167.
Try the demo on the connect screen signs in through the same bare
client as any sign-in, so headers typed for that address are kept
with the demo connection (and a demo connection can take another
connection's set for the same server). The Account page hid the card
from every demo account, which left a stored credential that could
only be dropped by signing out. A demo account with a set now gets
the card; one without still does not.

Part of #167.
The migration moved a connection's proxy headers best effort in its clean-up
and deleted the old entry even when the copy had failed, losing the set. They
are now copied the way the token is: written, read back and compared before
anything else changes, a failure aborting the run with the old entry kept so
the next launch tries again. The old entry is deleted only in the clean-up,
after every copy was verified.

Part of #167.
The sign-in step built its client with whatever set any signed-in connection
held for the address, so reconnecting one person could send another person's
proxy credentials. A reconnect's client now names the connection it signs in
again (connectClient's ownId), and connectProxyHeaders with an id answers only
the set typed for that address on the connect screen or that connection's own
set, never another connection's. finishConnect asks the same way, so what a
re-pair keeps is what its sign-in sent.

Part of #167.
A set bound to an http home address rode the tokenless server_id probe every
90 seconds, so a proxy credential crossed the house network in the clear. The
rule is now https only, at the root: cleanProxyHeaders refuses a set for an
http origin (at entry and at load), proxyHeaderRecord sends none to an http
URL whatever the set names, proxyOriginChoices offers https addresses only,
and the connect screen and the Account card say so for an http address. The
probe needs no case of its own.

Part of #167.
The Account card and the connect screen promised the headers go to one address
"and nowhere else", in all six languages. The app sends them only with its own
requests to that address, but redirects are followed by each platform's HTTP
stack and nobody has checked whether custom headers follow a 3xx to another
origin. The strings now say "sent with requests to <address>", a locale test
refuses the stronger phrases, and CONVENTIONS.md records the redirect check
still owed on a device.

Part of #167.
The captions added for an http address said the headers are "sent over HTTPS
only", the same kind of claim the redirect wording fix removed: a redirect
from an https request could name an http address, and what the platform does
then is unchecked. They now say proxy headers need an https address, the
locale test refuses the "HTTPS only" phrasing in every language, and the code
comment and CONVENTIONS.md say the app never puts them on a plain-http
request. The Account card test covers the address chooser again, with an
https home address.

Part of #167.
The file comment said the https-only rule three ways and ran past the
wrap width; proxyHeaderRecord kept a null check its comparison already
covers; the connect screen normalised and parsed the typed address
twice per keystroke.

Part of #167.
The parity check compared only proxyHeaders' top-level keys, so a
locale missing a string under proxyHeaders.problem still passed.

Part of #167.
A reconnect to a server that was reset finds no existing connection
(the server_id is new), so finishConnect looked the set up unscoped and
could store another person's set for the same address with the new
connection, although the sign-in itself carried only the reconnecting
connection's own. It now scopes the lookup to the reconnect's id, the
same set the sign-in client used.

Part of #167.
The copy kept a set already under the new id. After a run cut short
(its copy written, then an abort at the plan or file step), a set the
listener edited or removed under the old id lost to that stale copy on
the next launch, and the clean-up then deleted the edited set: a
rotated proxy token reverted to the revoked one, a removed one came
back. The copy now writes over the target, or deletes it when the
connection has none, as the token copy does. planMoves never targets a
live id, so nothing current is overwritten.

Part of #167.
When the new server_id already had a connection (another person who
reconnected first), a reconnect whose scoped lookup found nothing
passed no set, and setSession gave the new connection the first set
any connection to that server had: the other person's. finishConnect
now passes the reconnected identity's own set, or none, explicitly.

Part of #167.
A stored set the rules now refuse (one for an http origin, or a damaged item)
was dropped from the connection at launch but left in secure-store, a
credential with no use that would never be cleaned up. The hydrate now
deletes it, as it already did for a connection dropped with its token gone.

Part of #167.
A link or QR re-pair can't name its connection before the exchange answers,
so its client borrows another connection's set for the address. When the
connection had no set of its own, the exchange got through the proxy on the
borrowed set, finishConnect then stored none (own or typed only), and every
later request was turned away.

connectClientFor now names the set a connect client's requests carry, the
pairing exchange and the sign-in step hand it to finishConnect as `carried`,
and a connection with no set of its own and nothing typed keeps it. A
reconnect still keeps only its own or the typed set, and a set of the
connection's own for another address is never replaced.

Part of #167.
connectClientFor worked out whether a set goes to the address a second
time, beside the ApiClient constructor that decides it for real, so the
two rules could drift and finishConnect could keep a set the exchange
never sent. It now asks the client (proxyHeadersFor) and names the set
as undefined when none went, so callers pass it straight through; the
link exchange returns a named result instead of a three-item tuple.

Part of #167.
The demo button built its client with connectClient and never told
finishConnect which set carried the demo session. A demo connection
already on this device with no set of its own, whose demo got through
the proxy on another connection's set for the address, was stored with
none, so every later request was turned away. It now passes the set as
`carried`, like the sign-in step and a pairing link.

Part of #167.
CONVENTIONS.md, the finishConnect comment and the connectProxyHeaders
doc still said a re-pair never keeps another connection's set for the
address. A re-pair with no set of its own now keeps the set its
exchange got in on, so the three say so.

Part of #167.
@KodeStar
KodeStar merged commit 74b1777 into main Nov 14, 2026
1 check passed
@KodeStar
KodeStar deleted the feat/custom-headers branch November 14, 2026 00:27
KodeStar added a commit to KodeStar/audiosilo-docs that referenced this pull request Nov 14, 2026
… for a sign-in proxy (#305)

Docs for v2.7.0 row 15b item 2 and row 15's custom headers.

- **Install on a NAS (closes #277):** a new Synology page (Container
Manager project, the folder's owner as PUID / PGID, Shared Folder
permissions, where the log is; DSM labels not yet checked on a real NAS,
and the page says so). The Docker quickstart creates the data folder
first, gains a `docker run` example, and links Get the apps. Backups
live in `data/backups`; a backups folder elsewhere needs the same owner
and is never a share mounted at `/data/backups`.
- **Troubleshooting `#permission-denied`:** quotes the container's
warning lines, the start error, the "didn't answer within 5 seconds"
line and the library offline line verbatim from the server's verified
output.
- **First run:** only a plain visit to `/admin` opens setup; the setup
step's links open their page (server#365).
- **Proxy headers (fe#167):** "Behind a sign-in proxy" in Remote access,
the Account page's Proxy headers card, https only, what can't send them
(web cross-origin, browser images and audio, Chromecast, watches,
calendar apps). Developer docs: storage key, API client, connect screen,
cast block reason `proxy`.
- **Developer docs:** a Folder permissions section (the server's own
checks and the Docker entrypoint), the backup `detail` field, scanner
offline rule, setup redirect rule.
- Screenshot: `web-player/account.png` recaptured with the Proxy headers
card.

Build green (links and anchors checked). Reviewed with /simplify and
/code-review.

**Merge after** KodeStar/audiosilo-server#381 and
KodeStar/audiosilo-frontend#294; **before** KodeStar/audiosilo-site#69
(its links point here).
KodeStar added a commit to KodeStar/audiosilo-site that referenced this pull request Nov 14, 2026
A `draft: true` feature post, `nas-and-sign-in-proxy`, for the work
merged in KodeStar/audiosilo-frontend#294 (proxy headers),
KodeStar/audiosilo-server#381 (setup deep links, Docker permission
failures that name the folder, the user and the fix) and
KodeStar/audiosilo-docs#305 (Install on a Synology NAS, permission
troubleshooting).

Drafts are excluded from the build, so this can merge now; the 2.7.0
release publishes it.

- Claims checked against the code on main and the docs (one review at
high). It quotes the log text and console labels verbatim from the docs,
scopes proxy headers to the one https address they were entered for, and
covers Android passing them on through a redirect.
- Before publishing: the iOS device run for proxy headers (fe#294) and a
real NAS run (server#381) are still owed. The Synology steps were
written for DSM 7.2 and the post says they have not been checked on a
NAS.
- Gate: `yarn build && yarn run check` green.
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.

Send custom headers to a server behind an auth proxy

1 participant