fix: Keep BookOrbit sign-in details on the server they belong to
Some checks are pending
Build / build (push) Waiting to run

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.
This commit is contained in:
Chmouel Boudjnah 2026-09-24 14:35:11 +02:00
parent 5a797265b6
commit dbcde0bf13
2 changed files with 35 additions and 8 deletions

View file

@ -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())
}
}

View file

@ -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())