fix(android): send native request origins for cookie sessions
Derive Origin from each QQQ request URL after caller headers so strict same-origin servers accept session heartbeats and process actions. Verify 174 JVM tests, lint, APK build, and actual TC22 sign-in, Echo setup and cancellation without clearing data. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47a923a712
commit
fb3cc5d2f8
11 files changed
+172
-6
No files matched your search
@@ -1,3 +1,11 @@
|
||||
## November 2 — native cookie Origin compatibility verified on TC22
|
||||
|
||||
The combined Echo Demo on57832 (reached through device port8080) returned200 for login, then403 for the cookie-bearing session heartbeat POST, because Android's native QQQ requests omitted `Origin`. The server's same-origin guard remains enforced. Core now has a small `SameOriginInterceptor`: it sets `Origin` from the actual request URL's canonical scheme/host/port only (default ports omitted, IPv6 bracketed, never credentials/input/headers) and is installed last so additional headers cannot override it. It is used by `NetworkQQQRepository` and the `DeviceApi`, `MobileSessionApi` and `RecordFilesApi` clients. Version discovery, presentation/logo fetches, OIDC/browser/token requests and redirect refusal are unchanged.
|
||||
|
||||
All174 JVM tests passed (44core/55mobileapp/75host), with zero failures/errors/skips. Lint has zero errors/warnings and two existing informational hints; debug APK build passed. MockWebServer tests cover exact Origin plus cookie on real POSTs, canonicalization, override resistance and redirect non-forwarding. Session and record-file host tests assert the header on their actual requests.
|
||||
|
||||
The verified APK (SHA256 `0B872005B2FA84F693E73CC49133F5C1C02F732A034AA0635A2B50682A61A98E`) was installed on the normal TC22 without clearing its data. Existing scanner login and device approval work against the actual combined server; heartbeat remains connected. Echo opens its three required choices, the approved printer and its reported media load, and Cancel -> Leave returns to the app normally. This directly qualifies setup/session/cancel against the strict host, not a physical print. The earlier112-request/two-synthetic-scan acceptance remains separate. Physical printing awaits the printing owner's4x1 media report; the corresponding4x1 document is already published locally. Emulator stays off.
|
||||
|
||||
## November 1 — required Document choice verified on the real TC22
|
||||
|
||||
Echo asks for printer, paper/label size and Document once; all subsequent scans use those server-pinned choices. Android production code is unchanged: the generic semantic INTEGER/PVS/dependency controls handle it. The updated separate E2E app passed all7 stages on the actual TC22 (select Document, clear stale choices on parent change, validate required fields, queue two scans, recreate without duplication, Apps/Leave/CLOSED). All112 requests matched published semantic operations. Disposable app/reverse/fixture cleanup passed, normal app/data preserved, emulator off. Printing and scanner inputs are simulated; physicalScannerQualified/physicalPrintingQualified remain false.
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
# TODO
|
||||
|
||||
- [x] Verify native `Origin` compatibility:174 JVM tests, lint0errors0warnings and APK build pass; normal TC22 login/approval/heartbeat/Echo setup/printer-media choices/Cancel-Leave pass against the strict local Echo host through8080->57832, preserving data.
|
||||
|
||||
- [x] Verify generic INTEGER Document choice/dependency clearing on actual TC22:7 stages,112 semantic requests,99 focused/host tests and8 Node guards; simulated printing, normal data preserved.
|
||||
- [ ] Complete actual owner printer pairing/current-media document publication and physical scan/print/rescan; publish qualified public artifacts after current CI, signing last.
|
||||
- [x] Connect the installed print client to the single local server and publish the actual Echo4x1 document through native QQQ processes.
|
||||
- [ ] Resolve the printing agent's reported4x6 media versus loaded4x1 stock and complete physical scan/print/rescan; qualify public artifacts/current CI, signing last.
|
||||
|
||||
- [x] Add metadata-driven Echo acknowledgment/manual-entry hints with old defaults; 167 JVM tests, strict compilation, lint zero errors/warnings and debug build pass.
|
||||
- [ ] Qualify native Echo setup/scan/print-acceptance/leave with the actual owner adapters and independently decoded physical output; source tests are not physical printing evidence.
|
||||
|
||||
+2
@@ -106,6 +106,8 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
}
|
||||
response
|
||||
}
|
||||
// Last, so a caller-supplied additional header can never override the request's own origin.
|
||||
.addInterceptor(SameOriginInterceptor())
|
||||
.build()
|
||||
)
|
||||
.baseUrl(baseUrl)
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.controllers
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/*******************************************************************************
|
||||
** Native requests have no browser to supply `Origin`, yet cookie-bearing QQQ mutations must present their own origin.
|
||||
** The value is always the canonical scheme/host/port of the actual request URL: never credentials, input or header state.
|
||||
** Install it only on clients that talk to the selected QQQ server, never on provider, logo or download clients, and
|
||||
** after any header-adding interceptor so that it has the final say.
|
||||
*******************************************************************************/
|
||||
class SameOriginInterceptor : Interceptor {
|
||||
/*******************************************************************************
|
||||
** Replace any existing `Origin` with the origin of the URL being requested; redirects stay refused by the clients.
|
||||
*******************************************************************************/
|
||||
override fun intercept(chain: Interceptor.Chain): Response =
|
||||
chain.proceed(chain.request().newBuilder().header("Origin", httpOrigin(chain.request().url)).build())
|
||||
|
||||
companion object {
|
||||
/*******************************************************************************
|
||||
** `scheme://host[:port]` with the scheme's default port omitted and IPv6 hosts bracketed.
|
||||
*******************************************************************************/
|
||||
fun httpOrigin(url: HttpUrl): String {
|
||||
val host = if (url.host.contains(':')) "[${url.host}]" else url.host
|
||||
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
||||
return "${url.scheme}://$host$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -140,6 +140,38 @@ class NetworkQQQRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cookie-bearing native mutations (session/process POSTs) carry the exact request origin, and a caller-supplied
|
||||
** additional header can neither add a different Origin nor remove the real one. Login (no cookie yet) carries it too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun cookieBearingPostsCarryTheExactRequestOriginEvenIfAdditionalHeadersTryToSetOne() = runBlocking()
|
||||
{
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"type":"COMPLETE","processUUID":"process","nextStep":"result","values":{}}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}"))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin, additionalHeaders = { mapOf("Origin" to "https://evil.example", "X-QQQ-Device-Id" to "test-device") })
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
repository.setSessionUUID("synthetic-session")
|
||||
repository.processStep("edit", "process", "input", mapOf("a" to "b"), 1000)
|
||||
repository.logout()
|
||||
server.takeRequest()
|
||||
// Forwarded debug servers may listen elsewhere (for example 8080): only the URL the client used matters.
|
||||
val expected = "http://${server.hostName}:${server.port}"
|
||||
listOf(server.takeRequest(), server.takeRequest()).forEach { request ->
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("sessionUUID=synthetic-session", request.getHeader("Cookie"))
|
||||
assertEquals(expected, request.getHeader("Origin"))
|
||||
assertEquals(1, request.headers.values("Origin").size)
|
||||
assertEquals("http://" + request.getHeader("Host"), request.getHeader("Origin"))
|
||||
assertEquals("test-device", request.getHeader("X-QQQ-Device-Id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.controllers
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/*******************************************************************************
|
||||
** Native clients present the exact origin of the URL they actually request, canonicalized like a browser.
|
||||
*******************************************************************************/
|
||||
class SameOriginInterceptorTest {
|
||||
/*******************************************************************************
|
||||
** The origin comes only from scheme/host/port: a debug loopback forward keeps its device-side port
|
||||
** (whatever the server listens on behind it), defaults are omitted, and IPv6 hosts stay bracketed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun originIsTheCanonicalSchemeHostAndPortOfTheRequestUrl() {
|
||||
fun origin(url: String) = SameOriginInterceptor.httpOrigin(url.toHttpUrl())
|
||||
assertEquals("http://127.0.0.1:57832", origin("http://127.0.0.1:57832/qqq/v1/manageSession"))
|
||||
assertEquals("http://127.0.0.1", origin("http://127.0.0.1:80/"))
|
||||
assertEquals("https://qqq.example.test", origin("https://qqq.example.test/"))
|
||||
assertEquals("https://qqq.example.test", origin("https://qqq.example.test:443/path"))
|
||||
assertEquals("https://qqq.example.test:8443", origin("https://qqq.example.test:8443/"))
|
||||
assertEquals("http://qqq.example.test:443", origin("http://QQQ.Example.Test:443/"))
|
||||
assertEquals("http://[::1]:57832", origin("http://[::1]:57832/"))
|
||||
assertEquals("https://[2001:db8::1]", origin("https://[2001:db8::1]:443/"))
|
||||
assertEquals("Credentials, path, query and fragment are never part of an origin", "http://127.0.0.1:57832",
|
||||
origin("http://user:secret@127.0.0.1:57832/a/b?token=x#fragment"))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The header the interceptor sets is final, even when an earlier layer tried to supply a different one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun overridesAnyExistingOriginAndMatchesTheServerRequestHost() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody("{}"))
|
||||
server.start()
|
||||
val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).addInterceptor(SameOriginInterceptor()).build()
|
||||
client.newCall(Request.Builder().url(server.url("/")).header("Origin", "https://evil.example").build()).execute().close()
|
||||
val request = server.takeRequest()
|
||||
assertEquals("http://" + request.getHeader("Host"), request.getHeader("Origin"))
|
||||
assertEquals(1, request.headers.values("Origin").size)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A redirect is still refused, so neither the cookie nor this origin reaches another server.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun refusedRedirectNeverReachesAnotherOrigin() {
|
||||
MockWebServer().use { first ->
|
||||
MockWebServer().use { other ->
|
||||
first.start()
|
||||
other.start()
|
||||
first.enqueue(MockResponse().setResponseCode(307).setHeader("Location", other.url("/stolen").toString()))
|
||||
val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).addInterceptor(SameOriginInterceptor()).build()
|
||||
client.newCall(Request.Builder().url(first.url("/")).header("Cookie", "sessionUUID=synthetic").build()).execute().use { assertEquals(307, it.code) }
|
||||
assertEquals(1, first.requestCount)
|
||||
assertEquals(0, other.requestCount)
|
||||
assertNull(other.takeRequest(100, java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import android.os.Build
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -33,7 +34,7 @@ class DeviceApi(
|
||||
var displayName: String = ""
|
||||
private set
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).build()
|
||||
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
/*******************************************************************************
|
||||
** Check enrollment on demand; approval remains a server-side decision.
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
|
||||
import java.io.IOException
|
||||
@@ -53,7 +54,7 @@ internal class MobileSessionApi(
|
||||
onOutcome: (RequestOutcome) -> Unit = {},
|
||||
) : MobileSessionTransport {
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build()
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the current eligible session metadata and accept only the server's bounded lease policy.
|
||||
|
||||
@@ -4,6 +4,7 @@ package io.qrun.mobile.scanner
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
|
||||
import java.io.File
|
||||
@@ -156,7 +157,7 @@ internal class RecordFilesApi(
|
||||
private val compatibility: UiApiCompatibility, onOutcome: (RequestOutcome) -> Unit = {}
|
||||
) : RecordFilesTransport {
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build()
|
||||
.callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
@Volatile
|
||||
private var captureContractVerified = false
|
||||
|
||||
@@ -328,7 +328,16 @@ class MobileSessionTest {
|
||||
val screen = server.takeRequest()
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path)
|
||||
assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence"))
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/end", server.takeRequest().path)
|
||||
val end = server.takeRequest()
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/end", end.path)
|
||||
// Cookie-bearing native POSTs must present the exact origin of the URL used, like a browser.
|
||||
val expectedOrigin = origin.trimEnd('/')
|
||||
listOf(heartbeat, screen, end).forEach { request ->
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("sessionUUID=synthetic-session", request.getHeader("Cookie"))
|
||||
assertEquals(expectedOrigin, request.getHeader("Origin"))
|
||||
assertEquals(1, request.headers.values("Origin").size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,15 @@ class RecordFilesApiTest {
|
||||
val finalize = server.takeRequest()
|
||||
assertEquals(intent.requestId, finalize.getHeader("Idempotency-Key"))
|
||||
assertEquals(0L, finalize.bodySize)
|
||||
assertEquals("GET", server.takeRequest().method)
|
||||
val status = server.takeRequest()
|
||||
assertEquals("GET", status.method)
|
||||
// Every native record-file request (including the cookie-bearing mutations) presents its own exact origin.
|
||||
val expectedOrigin = origin.trimEnd('/')
|
||||
listOf(descriptor, capabilities, first, second, chunk, finalize, status).forEach {
|
||||
assertEquals(expectedOrigin, it.getHeader("Origin"))
|
||||
assertEquals(1, it.headers.values("Origin").size)
|
||||
}
|
||||
listOf(capabilities, first, second, chunk, finalize, status).forEach { assertEquals("sessionUUID=synthetic-session", it.getHeader("Cookie")) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user