summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-11-05 09:09:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-11-05 09:09:50 +0000
commitca385bfac567697f050ccaa506f9fb27681750de (patch)
tree55c6aa91cf7725f7a6a087ec52e54673e7cf9738 /server
parent32df466c5c851c1a3e88c973627a9c07d2634e2b (diff)
The car plays our copy too, and the transcript URL stops leaking the token
**Android Auto was the one caller still streaming from publishers.** `onAddMediaItems` resolved a browse id straight to a media item without asking whether the audio existed, so an undownloaded episode was handed to the player as its publisher's URL. That is the worst place for it: the car is where most of this library is actually heard, and misplaced skips are least obvious there. It goes through `PlaybackAdvancer.startFetchingIfNeeded` now -- start, and fetch first if there is no file -- the same decision the in-app paths make, in the one class tested for it. A fetch that fails stays failed rather than falling back: the publisher's copy carries different advertising, so the server's measured breaks land in the wrong place, and silence that can be retried beats skipping into the programme. With that, nothing reached the fallback, so it is gone. `setUri(localPath ?: mediaUrl)` is now `setUri(localPath)`, `MediaUrl` is deleted, and OutboundRequestsTest asserts zero remote media items rather than pinning one. `start` also reports Gone for an episode whose show *and* file have both gone, which is what made the fallback removable -- an orphan that still has its file plays. **Two findings from the security review of the last commit, both mine, both real:** - `Request.Builder().url()` throws on a URL it cannot parse and only IOException was caught, so a feed with a junk enclosure threw straight past the Result its caller folds. Survivable when this ran from a worker; not now that every play goes through a fetch, where one bad enclosure in one feed would stop any episode starting. Caught in the downloader, and `fetchAndStart` turns a thrown fetch into FetchFailed -- rethrowing CancellationException, which a test now pins, because swallowing it would report a cancelled start as a failed one and leave a false error on screen. - `FeedFetcher` logged whole feed URLs. Private feeds carry a token in the query and some carry credentials in the userinfo. `FeedFailureText.redact` cuts them to scheme and host, and is tested as the security control it is rather than as tidiness. The unparsable branch logs neither the address nor the exception, and says why: that exception's message *is* the address, so logging the cause would put back exactly what redacting removes. **The transcript URL no longer carries the token.** Apache logs the request line, so every /api/transcript fetch wrote the server's token into its access log in plaintext, across fourteen rotations. It was redundant there: the phone sends Authorization: Bearer anyway and tokenOK takes either. The audio URL still carries one -- not redundant, the downloader has no credentials of its own -- so removing that needs an app change installed before the server stops emitting it. The gate refused this twice and was right both times. The coverage drop was not all deletion: the redaction and the cancellation contract were genuinely untested, and have tests now. Then detekt caught an import left behind by moving the redaction into FeedFailureText -- a move I made after the last time I ran it. 1031 app tests, 376 server tests, detekt 0, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server')
-rw-r--r--server/internal/api/api.go6
-rw-r--r--server/internal/api/api_test.go44
-rw-r--r--server/internal/api/transcript_test.go9
3 files changed, 54 insertions, 5 deletions
diff --git a/server/internal/api/api.go b/server/internal/api/api.go
index d716bb4..2d2e279 100644
--- a/server/internal/api/api.go
+++ b/server/internal/api/api.go
@@ -278,8 +278,10 @@ func (s *Server) transcriptNoteFor(e *store.Episode) *transcriptNote {
Note: e.TranscriptNote,
}
if e.HasTranscript() {
- note.URL = "/api/transcript?episode=" + url.QueryEscape(e.ID) +
- "&token=" + url.QueryEscape(s.opts.Token)
+ // No token. The phone sends Authorization: Bearer on this request anyway and tokenOK
+ // takes either, so putting it in the URL bought nothing and cost a copy of the token in
+ // every line of Apache's access log -- which keeps fourteen rotations.
+ note.URL = "/api/transcript?episode=" + url.QueryEscape(e.ID)
if held, err := (transcript.Store{Dir: s.opts.TranscriptDir}).Get(e.ID); err == nil {
note.Ads = len(held.Ads)
for _, ad := range held.Ads {
diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go
index 8a29b33..b50454b 100644
--- a/server/internal/api/api_test.go
+++ b/server/internal/api/api_test.go
@@ -441,3 +441,47 @@ func TestAskingForAnEpisodeMarksItWanted(t *testing.T) {
t.Error("asking for a mirrored episode left it unwanted, so a poll may still drop it")
}
}
+
+/*
+The transcript URL carries no token.
+
+Apache logs the request line, so every /api/transcript fetch wrote the server's token into
+listen.terst.org-access.log in plaintext, across fourteen rotations. The token was redundant
+there in the first place: the phone sends Authorization: Bearer on this request anyway, through
+OkHttpSkipTransport, and tokenOK accepts either.
+
+The audio URL still carries one. That one is not redundant -- the downloader fetches it with no
+credentials of its own -- and removing it needs the app to send the header first, so it goes in
+a later change.
+*/
+func TestTheTranscriptURLDoesNotCarryTheToken(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "audio-bytes")
+ e := st.Match("https://f", "g1", "", "")
+ e.TranscriptSource, e.TranscriptCues = "asr", 12
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1")
+
+ var got episodeResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Transcript == nil || got.Transcript.URL == "" {
+ t.Fatalf("no transcript url in %s", rec.Body.String())
+ }
+ if strings.Contains(got.Transcript.URL, "token") {
+ t.Errorf("the token is in the transcript url, and so in every access log line: %s", got.Transcript.URL)
+ }
+}
+
+// And the request still works, because the header is what authorises it.
+func TestTheTranscriptEndpointTakesTheHeader(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "audio-bytes")
+
+ rec := get(t, s, "/api/transcript?episode="+episodeID(st))
+
+ if rec.Code == http.StatusUnauthorized || rec.Code == http.StatusForbidden {
+ t.Errorf("a bearer header must authorise this; got %d", rec.Code)
+ }
+}
diff --git a/server/internal/api/transcript_test.go b/server/internal/api/transcript_test.go
index e6da3ff..dff4d54 100644
--- a/server/internal/api/transcript_test.go
+++ b/server/internal/api/transcript_test.go
@@ -106,9 +106,12 @@ func TestTheResponseSaysWhereTheWordsAre(t *testing.T) {
if got.Transcript == nil || !strings.Contains(got.Transcript.URL, e.ID) {
t.Fatalf("got %+v, want a URL naming the episode", got.Transcript)
}
- // Usable as given, like the audio URL: the phone appends nothing.
- if !strings.Contains(got.Transcript.URL, "token=") {
- t.Errorf("url = %q, want it to carry the token", got.Transcript.URL)
+ // Not usable as given, deliberately. The phone authorises this with a bearer header, which
+ // it was sending anyway, and a token in the URL was a copy of the secret in every line of
+ // Apache's access log. The audio URL still carries one because the downloader has no
+ // credentials of its own; see TestTheTranscriptURLDoesNotCarryTheToken.
+ if strings.Contains(got.Transcript.URL, "token=") {
+ t.Errorf("url = %q, want no token in it", got.Transcript.URL)
}
}