Skip to content

Release 0.10.0 — an owner-only app can keep one door open for its own API - #417

Merged
onel merged 4 commits into
mainfrom
release/0.10.0
Aug 14, 2026
Merged

Release 0.10.0 — an owner-only app can keep one door open for its own API#417
onel merged 4 commits into
mainfrom
release/0.10.0

Conversation

@onel

@onel onel commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two changes since 0.9.0.

Path-scoped app exposure (#415, PR #416)

Hosted exposure was whole-app and binary, which left no room for the app that pairs a token-authed API with a session-authed UI: letting an external SDK reach the API meant making the whole app public, which dropped the box login in front of the UI too. Langfuse ships with exactly that limitation; Laminar is worse, because its self-hosted UI signs in any email with no password.

A manifest can now declare access.public_paths (APP_MANIFEST.md # E2). A restricted app's route becomes one Caddy subroute — the declared paths proxy straight to the app, everything else stays gated — so the app keeps the box login on its UI forever while its API answers anonymously.

Two things went wider than the feature itself:

  • Identity headers are now scrubbed on every hosted app route, not only where the forward-auth gate runs. The gate does not run on a public path, and it never ran on a fully public app either — so an app that learned to trust X-Malmo-User while owner-only kept trusting it the moment the owner flipped the toggle to Public. That hole is closed for every hosted app, not just the ones using the new field.
  • The dashboard tells the truth. An app with declared public paths names them on its Access control instead of claiming a bare "Only me", read from the instance's own manifest copy so the label cannot drift from the route.

Also fixed in the same PR: the hosted boot lane matched its guest verdict with a *PASS* glob, so any assertion failing with bypass, password or passphrase in its message was read as a pass. A genuinely red access boot printed boot access OK and exited 0. The verdict is now matched exactly — this was latent across every boot in that lane, not just the new assertions.

Container logs reach journald (#414)

The dashboard's per-app Logs tab was empty on every real box, for every app, since the tail shipped — no image ever wrote /etc/docker/daemon.json, so both profiles ran Docker's json-file default while host-agent queried journalctl CONTAINER_NAME=…, a field only the journald driver sets.

Verification

The hosted boot proof is green on all five boots (unseeded seeded bios access update) at the merged tree, with the access boot now also proving the new carve-out through real Caddy: declared paths answer with no session, a forged X-Malmo-User never reaches the app on either branch while the vouched one still does, malmo_forward_auth is stripped on both branches, and an undeclared-path table (prefix footgun, traversal, encoded traversal, double slash, case variant) never reaches the app upstream.

Note for whoever reads VERSION on dev

dev still carries 0.6.0 — release bumps land on main via the release branch and have never been merged back, so dev builds stamp a version three releases old. Pre-existing drift, not introduced here, but it makes a dev build's malmo 0.6.0 stamp misleading.

onel added 4 commits August 13, 2026 22:26
…how (#414)

The per-app Logs tab was empty on every real box, for every app, since it
shipped: it opened the stream and waited forever.

host-agent-real tails an app by running
`journalctl CONTAINER_NAME=<container> -f`, and CONTAINER_NAME is a field
only Docker's journald log driver sets. LOGGING.md calls that driver switch
"the single biggest configuration decision", but no image ever wrote
/etc/docker/daemon.json, so both real profiles ran Docker's json-file
default and the match returned nothing.

Two blind spots hid it, both the same mistake: the inner loop's fake
host-agent reads `docker logs -f`, and the cloud boot proof greps the brain
through `docker logs`. Both work on any driver, so nothing exercised the
query the feature depends on.

Ship the driver on both images, disable journald's per-unit rate limit for
dockerd (every container now shares docker.service's one bucket, so a chatty
container would silently starve the rest), and assert in the boot proof that
`journalctl CONTAINER_NAME=malmo-brain` returns lines -- not via docker logs,
since reading it that way is why this shipped.

The journal is still volatile; persistence is the next entry.
* Let an owner-only app keep one door open for its own API (#415)

Hosted exposure was whole-app and binary, so an app that pairs a
token-authed API with a session-authed UI had to go fully public for
its SDK to work — which dropped the box login in front of the UI too.

A manifest may now declare `access.public_paths`. A restricted app's
route becomes one Caddy subroute: the declared paths proxy straight to
the app, everything else stays gated. One route and one @id, so the
insert-at-0 ordering against the catch-all is unchanged, and the proxy
handler is built once and used on both branches so the per-cookie strip
cannot land on one and not the other.

The identity headers are now scrubbed on every hosted app route, not
only where the gate runs. The gate does not run on a public path, so
nothing there would overwrite a caller-supplied X-Malmo-User; the same
hole already existed for a fully public app.

The dashboard names the open paths instead of claiming a bare "Only me",
reading them from the instance's own manifest copy so the label cannot
drift from the route. The hosted lane's access boot carries the proof
through real Caddy, including a bypass table that must stay gated.

* Carry the declared paths on the exposure echo too, not just the detail GET

The access label is exposure plus declared public paths together, so the
toggle's echo carrying only the exposure would render "Only me" for an
app with open paths until the next refetch. Both responses now go
through one withPublicPaths helper.

* Fix the bypass table's wrong expectation, and the glob that hid its failure

Two things the first cloud run found.

//v1/ never reaches the gate: Caddy collapses the duplicate slash and
301s to the normalized path before matching, so the app is not reached
and the expectation was wrong, not the probe. The table now asserts the
claim that matters — an undeclared path never reaches the app upstream
anonymously — and allows the merge-redirect shape for that entry.

Worse, that failure was reported green. run-cloud-tests.sh matched the
guest verdict with a *PASS* glob and the reason read 'PATH GATE BYPASS',
which contains PASS, so a red access boot printed 'boot access OK'. The
verdict is now matched exactly. Any failure text containing bypass or
password would have done this to any boot in this lane.
@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

The PR should not merge until failed exposure updates can no longer leave a supposedly restricted hosted app publicly reachable.

The exposure update stores the new owner-only policy before a newly introduced manifest read, so that read can fail before Caddy receives the route containing the forward-auth gate.

Files Needing Attention: internal/lifecycle/lifecycle.go

Security Review

A failed public-to-restricted exposure change can persist the restricted state without rebuilding the active Caddy route, leaving the app anonymously reachable while the API reports it as owner-only. How this was verified: The changed path commits the exposure before its fallible manifest read, and the only subsequent AddRoute call that installs the gate is skipped when that read fails.

Important Files Changed

Filename Overview
internal/lifecycle/lifecycle.go Integrates manifest public paths and identity-header scrubbing into every hosted route, but exposure updates can commit before a new manifest-read failure prevents route reconstruction.
internal/caddy/caddy.go Builds a scrub-first subroute with direct public-path proxying and a gated fallback while retaining forward-auth cookie stripping.
internal/manifest/manifest.go Adds conservative validation for exact and trailing-wildcard public paths, rejecting ambiguous normalization and whole-app patterns.
internal/api/api.go Adds best-effort instance-manifest enrichment for public paths on detail responses.
internal/api/appexposure.go Returns public-path enrichment with exposure mutation responses, while relying on lifecycle to keep route policy synchronized.
web-ui/src/views/settings/InstalledAppDetailSection.vue Discloses manifest-declared anonymous paths in the restricted-access summary.
dev/cloud/mkosi.extra/etc/docker/daemon.json Switches Docker’s default container logging driver to journald.
dev/cloud/mkosi.extra/etc/systemd/system/docker.service.d/10-malmo-logging.conf Disables dockerd’s shared journald rate limit to prevent one container from silently starving other container logs.

Comments Outside Diff (1)

  1. internal/lifecycle/lifecycle.go, line 1113-1115 (link)

    P1 security Exposure state diverges from route

    When a running hosted app’s instance manifest is unreadable during a public-to-restricted change, SetExposure persists restricted before returning from the failed manifest load, so AddRoute never reinstalls the forward-auth gate and the app remains anonymously reachable while the API reports it as owner-only.

    How this was verified: The changed path commits the exposure at lines 1105–1108, while the fallible manifest read at lines 1113–1115 precedes the only subsequent AddRoute call.

    Knowledge Base Used:

Reviews (1): Last reviewed commit: "Merge main into release/0.10.0, keeping ..." | Re-trigger Greptile

@onel
onel merged commit b3f165d into main Aug 14, 2026
4 checks passed
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