Repository navigation
Proxy headers: send custom headers to a server behind a sign-in proxy - #294
Merged
Merged
Conversation
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.
This was referenced Nov 13, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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 ahasProxyHeadersmarker. Deleted wherever the token is; the identity migration copies with a read-back check and deletes the old entry only at clean-up.authHeaders(); downloads, car and widget covers viaproxyHeadersFor(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, reasonproxy), the watch apps, calendar apps. No server change.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).