From dbcde0bf13bb2df616cbcfe81ff7b79774b5dfca Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Thu, 24 Sep 2026 14:35:11 +0200 Subject: [PATCH] fix: Keep BookOrbit sign-in details on the server they belong to When a BookOrbit server answered a request by pointing somewhere else, the app followed it and could send the reader's session, and sometimes what was being saved, to an address it had never checked. Only saving a reading place or status was protected. Every BookOrbit request now refuses to be pointed elsewhere. --- .../liseur/data/bookorbit/BookOrbitHttp.kt | 24 ++++++++++++------- .../bookorbit/BookOrbitCatalogClientTest.kt | 19 +++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitHttp.kt b/app/src/main/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitHttp.kt index 166a09b7..07677b17 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitHttp.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitHttp.kt @@ -32,8 +32,18 @@ import org.json.JSONObject */ class BookOrbitHttp( private val session: BookOrbitSession, - private val http: RemoteHttp = RemoteHttp(), + http: RemoteHttp = RemoteHttp(), ) { + /** + * Never follows a redirect: the bearer and any request body would go + * wherever the server pointed, without [BookOrbitRequestContext.covers] + * seeing the new address. + */ + private val client = http.client.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + /** A mutation must not be replayed merely because its response was lost. */ sealed interface MutationResult { data object ReadBackRequired : MutationResult @@ -72,9 +82,7 @@ class BookOrbitHttp( val request = signed(context, url, bearer) .method(method, body).build() try { - http.client.newBuilder() - .followRedirects(false) - .followSslRedirects(false) + client.newBuilder() .retryOnConnectionFailure(false) .build().newCall(request).execute().use { response -> when { @@ -111,7 +119,7 @@ class BookOrbitHttp( url: String, ): JSONObject? = withContext(Dispatchers.IO) { session.authorized(context) { bearer -> - http.client.newCall(signed(context, url, bearer).build()).execute().use { response -> + client.newCall(signed(context, url, bearer).build()).execute().use { response -> if (!response.isSuccessful) throw RemoteHttpFailure(failureForCode(response.code)) val text = response.body?.string().orEmpty() if (text.isBlank()) null else asObject(text) @@ -127,7 +135,7 @@ class BookOrbitHttp( ): JSONObject? = withContext(Dispatchers.IO) { session.authorized(context) { bearer -> val request = signed(context, url, bearer).post(json.toString().toRequestBody(JSON)).build() - http.client.newCall(request).execute().use { response -> + client.newCall(request).execute().use { response -> if (response.code in rejected) return@authorized null asObject(body(response)) } @@ -154,7 +162,7 @@ class BookOrbitHttp( val request = signed(context, url, bearer) .method(method, if (method == "DELETE" && json == null) null else body) .build() - http.client.newCall(request).execute().use { response -> + client.newCall(request).execute().use { response -> if (response.isSuccessful || response.code in rejected) return@authorized response.code throw RemoteHttpFailure(failureForCode(response.code)) } @@ -166,7 +174,7 @@ class BookOrbitHttp( url: String, ): String = withContext(Dispatchers.IO) { session.authorized(context) { bearer -> - body(http.client.newCall(signed(context, url, bearer).build()).execute()) + body(client.newCall(signed(context, url, bearer).build()).execute()) } } diff --git a/app/src/test/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitCatalogClientTest.kt b/app/src/test/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitCatalogClientTest.kt index 7b92741f..f5818074 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitCatalogClientTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/data/bookorbit/BookOrbitCatalogClientTest.kt @@ -100,6 +100,25 @@ class BookOrbitCatalogClientTest { {"id":$fileId,"format":"epub","role":"$role","sizeBytes":1925282}]} """.trimIndent() + @Test + fun `an API call does not follow a redirect with the bearer or body`() = runBlocking { + val dao = FakeServerDao(account()) + val http = BookOrbitHttp(BookOrbitSession(dao, RemoteHttp(), now = { 1_000L })) + val context = BookOrbitRequestContext.from(account())!! + repeat(2) { + server.enqueue(MockResponse(code = 307, headers = okhttp3.Headers.headersOf("Location", "/elsewhere"))) + } + + val get = runCatching { http.getObject(context, "${address()}/api/v1/books/1") } + val post = runCatching { + http.postObject(context, "${address()}/api/v1/books/query", org.json.JSONObject().put("q", "x")) + } + + assertTrue(get.exceptionOrNull() is com.chmouel.liseur.data.remote.RemoteHttpFailure) + assertTrue(post.exceptionOrNull() is com.chmouel.liseur.data.remote.RemoteHttpFailure) + assertEquals(2, server.requestCount) + } + @Test fun `a book with no epub is not put on the shelf`() = runBlocking { val dao = FakeServerDao(account())