sync: tell the user which address their server got wrong

Two gaps left by fc7e520, both found in review.

The claimed path was kept when the claimed host was replaced, though
both come from the generator we had just decided not to trust. A
subdirectory install behind a proxy reports an empty webroot from inside
the container, so /nextcloud was dropped and discovery ran against the
wrong base -- the same dead end, one level down. The origin is now
rebuilt entirely from the poll endpoint, whose own prefix is whatever
precedes index.php/login/v2/poll.

And the mismatch rode out of poll() with nowhere to go. It is carried on
the state rather than the step, because it is learned during the browser
step while the setting it blames is what the user has to go and fix
afterwards -- so it has to outlive the step that discovered it.
This commit is contained in:
2026-09-07 22:09:17 +02:00
parent 3a9f623d2a
commit 730a1eb88d
6 changed files with 104 additions and 11 deletions
@@ -79,7 +79,7 @@ class NextcloudLoginFlow(
*/
fun start(server: HttpUrl, now: Long): Result<Flow> = runCatching {
val request = Request.Builder()
.url(server.newBuilder().addPathSegments("index.php/login/v2").build())
.url(server.newBuilder().addPathSegments(FLOW_START_PATH).build())
.header("User-Agent", userAgent)
// OCS-APIRequest is *not* needed: v2 is a Frontpage route, not OCS.
.post(FormBody.Builder().build())
@@ -243,10 +243,14 @@ class NextcloudLoginFlow(
* and the app password is already spent.
*
* So when the claimed origin is outside the registrable domain we just
* successfully polled, keep scheme, host and port from the poll endpoint —
* the origin empirically known to answer — and keep the claimed path.
* Coerced, never refused: by this point the credential exists and the 200 is
* spent, so throwing burns a live app password.
* successfully polled, rebuild it entirely from the poll endpoint — scheme,
* host, port *and* base path — which is the one URL empirically known to
* answer. The claimed path is dropped with the claimed host: both come from
* the same generator, and keeping half of a URL we have decided not to trust
* is how a subdirectory install loses its prefix. The endpoint's own prefix
* is whatever precedes `index.php/login/v2/poll`, which is where the flow
* was opened. Coerced, never refused: by this point the credential exists
* and the 200 is spent, so throwing burns a live app password.
*
* This compares one server-emitted origin against another, never against
* what the user typed, so a correctly configured proxy — which emits the
@@ -262,12 +266,26 @@ class NextcloudLoginFlow(
val polled = expected.topPrivateDomain() ?: expected.host
if (claimed.equals(polled, ignoreCase = true)) return secure
return expected.newBuilder()
.encodedPath(secure.encodedPath)
.encodedPath(baseOf(expected))
.query(null)
.fragment(null)
.build()
}
private companion object {
const val FLOW_START_PATH = "index.php/login/v2"
/** What `start()` appends, plus the poll leg the server adds to it. */
const val FLOW_PATH = "index.php/login/v2/poll"
}
/** The poll endpoint with the flow's own path removed — the server's web root. */
private fun baseOf(pollEndpoint: HttpUrl): String {
val path = pollEndpoint.encodedPath
val tail = path.indexOf(FLOW_PATH)
return if (tail >= 0) path.take(tail).ifEmpty { "/" } else "/"
}
/** A different host than the user typed — reported, not refused. */
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null
@@ -233,15 +233,30 @@ class NextcloudLoginFlowTest {
assertThat(flow.reachableOrigin(expected, actual)).isEqualTo(actual)
}
@Test fun `the claimed path survives the host being replaced`() {
@Test fun `a subdirectory install keeps its prefix when the host is replaced`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected =
"https://cloud.example.com/nextcloud/index.php/login/v2/poll".toHttpUrl()
// ⚠️ The container's own webroot is empty, so the claimed URL carries no
// prefix at all. Keeping the claimed path alongside the polled host would
// drop /nextcloud and send discovery to the wrong base — with the app
// password already spent.
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
assertThat(reachable.host).isEqualTo("cloud.example.com")
assertThat(reachable.encodedPath).isEqualTo("/nextcloud/")
}
@Test fun `a webroot install is rebuilt at the root`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
val reachable = flow.reachableOrigin(expected, "https://internal.local/nextcloud/".toHttpUrl())
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/whatever".toHttpUrl())
// A subdirectory install still lives under its subdirectory.
assertThat(reachable.host).isEqualTo("cloud.example.com")
assertThat(reachable.encodedPath).isEqualTo("/nextcloud/")
// The claimed path goes with the claimed host: both came from the
// generator we just decided not to trust.
assertThat(reachable.encodedPath).isEqualTo("/")
}
@Test fun `a single-label host is compared exactly, not by a null domain`() {