Skip to content

fix: honour a base URL path prefix, reject unusable server URLs, test wear in CI#35

Merged
SiteRelEnby merged 2 commits into
mainfrom
fix/self-hosting-baseurl
Jul 14, 2026
Merged

fix: honour a base URL path prefix, reject unusable server URLs, test wear in CI#35
SiteRelEnby merged 2 commits into
mainfrom
fix/self-hosting-baseurl

Conversation

@SiteRelEnby

Copy link
Copy Markdown
Collaborator

Third and last PR from this review round (security is #33, correctness is #34).

Self-hosting behind a path prefix never worked

val newUrl = original.url.newBuilder()
    .scheme(baseUrl.scheme)
    .host(baseUrl.host)
    .port(baseUrl.port)     // ... and that is all
    .build()

Endpoint paths are absolute (/v1/members), and Retrofits placeholder base is http://localhost/`, so
the interceptor only ever had to swap the origin. baseUrl.encodedPath was never read. Configure
https://example.org/sheaf/ and every request goes to https://example.org/v1/members - i.e. to whatever
lives at that domain`s root, which is not us. Only instances at the root of a domain have ever worked.

Now the path prefix is carried through. Pulled the rewrite out into a pure applyBaseUrl(original, base)
so it can be tested: origin-root, prefix, trailing slash, nested prefix, non-default port, query params,
pre-encoded segments, cleartext loopback. (Query params were never broken, despite the report - the
original builder preserves them.)

We advertised plaintext servers and then blocked them

targetSdk is 35, so the platform default is cleartextTrafficPermitted="false", and the only
network-security-config in the tree is the debug one (loopback + 10.0.2.2). Meanwhile the login screen
said "use http:// explicitly only for plaintext servers", Settings said "To use plaintext (e.g. a local
dev server), type the http:// prefix explicitly"
, and normalizeBaseUrl happily persisted it.

So on a release build a self-hoster could follow our own instructions, save http://my-server.local:8080,
and get an opaque network error on every request forever.

Rather than opening cleartext up app-wide in release (Android has no dynamic per-domain allowlist, so that
would permit cleartext to any host for every user), the address is now validated where it is entered:

  • non-loopback http:// is refused, with a message that says why
  • debug builds still accept the loopback set the debug config actually permits
  • the copy in both places now says https is required and that a path is fine

Also rejects a bare https://, which normalizeBaseUrl was turning into https://https: - which,
delightfully, parses.

CI never ran the wear tests

tagged-release.yml and dev-release.yml both ran ./gradlew :app:test. Both workflows build and ship
:wear:. So the one wear test we have has never run on a release, only on PRs. Both now run
:app:test :wear:test and upload the wear reports.

Verification

  • :app:assemblePlayRelease :wear:assembleRelease :app:testPlayReleaseUnitTest :wear:testPlayReleaseUnitTest green.
  • 17 new unit tests (8 for the URL rewrite, 9 for the validation).
  • Device pass worth doing: enter a server URL with a path and confirm it connects; try an http:// address
    and confirm the message; confirm an existing https config still works untouched.

Note for self-hosters

If anyone is already running behind a path prefix, they have been unable to use the Android app at all, so
there is no migration concern: it starts working, it does not change behaviour.

… wear in CI

BaseUrlInterceptor copied scheme/host/port from the configured base URL and
dropped everything else. Endpoint paths are absolute ("/v1/members"), so an
instance hosted at https://example.org/sheaf/ had every request sent to
https://example.org/v1/... instead: whatever happens to live at that domain's
root. Only origin-root deployments ever worked. The rewrite now carries the
base URL's path prefix through, extracted as a pure applyBaseUrl() with tests
(query params were already fine).

Cleartext: the app told users to "type the http:// prefix explicitly" for a
plaintext server, normalizeBaseUrl accepted it, and then targetSdk 35's default
(cleartextTrafficPermitted=false) blocked every request and surfaced it as a
generic network failure. The affordance we advertised only ever worked in debug,
against loopback, which is the only thing the debug network-security-config
whitelists. Validate the address when it is entered instead: non-loopback http
is refused with an explanation, debug builds still allow the loopback set, and
the misleading copy in both the login flow and Settings > Server is gone. Also
rejects a bare "https://", which normalizeBaseUrl otherwise mangled into
something that parses.

CI: the release workflows ran :app:test only, so every wear release (which they
build and ship) went out without its tests ever running. Both now run
:app:test :wear:test and collect the wear reports.
@SiteRelEnby
SiteRelEnby enabled auto-merge July 14, 2026 14:27
@SiteRelEnby
SiteRelEnby merged commit 01f9507 into main Jul 14, 2026
1 check passed
@SiteRelEnby
SiteRelEnby deleted the fix/self-hosting-baseurl branch July 14, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant