Skip to content

feat(webui): guard every Mini App loopback server with the host credential - #262

Closed
Fectivnfy112357 wants to merge 4 commits into
feat/miniapp-contract-forward-portfrom
feat/miniapp-loopback-guard
Closed

Fectivnfy112357 wants to merge 4 commits into
feat/miniapp-contract-forward-portfrom
feat/miniapp-loopback-guard

Conversation

@Fectivnfy112357

@Fectivnfy112357 Fectivnfy112357 commented Oct 26, 2026 •

Copy link
Copy Markdown

Part of #253. Stacked on #261: base is feat/miniapp-contract-forward-port, because the reader and
contract changes this branch builds on are still under review there. GitHub retargets this PR to
webui when #261 merges.

Change

A Mini App's port is derived from its plugin id (49152 + sha256(pluginId)), so a local process can
compute it rather than scan for it. This PR puts the Host guard in the Mini App's own process, in
front of every node:http server the plugin creates.

  • hostRunner patches http.Server.prototype.emit before the entry module loads, so any server the
    plugin starts inherits it. Only a request carrying the Host-issued credential on the exact loopback
    Host reaches plugin code; anything else is answered 401 with no body, and an upgrade/connect
    socket without it is destroyed. The reserved health path MINIAPP_LOOPBACK_HEALTH_PATH is answered
    204 by the guard itself.
  • The credential is stripped from headers and rawHeaders on success, so plugin code never observes
    it.
  • randomBytes(32).toString('base64url'), minted once per process generation in prepareRuntime
    before the port-attempt loop, carried on the init message, and kept on the runtime handle as
    server-only state. It is absent from MiniAppPublishedView and every renderer contract.
  • Readiness now requires an anonymous health request to be rejected with 401 and the credentialed one
    to be answered 204 (READINESS_PROBE_REJECTED, READINESS_PROBE_TIMEOUT), instead of a TCP connect.

Handing the credential to the proxy that injects it is deliberately not here: #255 owns the proxy and
is the first consumer of that hand-off, so this PR does not add an unread field to the supervisor.

Validation

  • pnpm typecheck — passed
  • pnpm run test:capabilities — 236 files, 5818 passed, 18 skipped
  • pnpm check:source after node scripts/source-inventory.mjs --write — 5452 files, passed
  • New suites, all against real servers: loopback-guard.test.ts (11 cases) drives a real http.Server
    through the real runner seam and exercises readiness against it plus a plain child-process server that
    answers 200; node-runtime-credential.test.ts (2 cases) runs the real runner in-process behind a
    scripted ChildProcess and observes the credential the runtime mints and hands down
  • Red proof: removing the guard install makes 6 of the 7 cases fail
  • NOT RUN: pnpm verify in full; the browser and release-package steps are left to CI

Untested boundaries

  • The connect (HTTP CONNECT) branch is implemented through the same socket-event allowlist as
    upgrade but is not covered by a case on its own.
  • pnpm verify in full was not run; the browser and release-package steps are left to CI.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes: none.

Shared-source port: not needed — the guard is this repository's own code, written against the shipped
generation's behaviour rather than copied from it.

…ntial

Work in progress on #253. The guard and the credential threading are in place;
the test suite and the wiring that hands the credential to the proxy are not.

A Mini App's port is derived from its plugin id, so a local process can compute it
rather than scan for it. Every `node:http` server the plugin creates is now
intercepted at the `http.Server.prototype.emit` seam before the entry module loads,
and only a request carrying the Host-issued credential on the exact loopback Host
reaches plugin code: anything else is answered 401 with no body, and an `upgrade` or
`connect` socket without it is destroyed. The credential is stripped from both
`headers` and `rawHeaders` on success, so plugin code never observes it. One
reserved health path is answered 204 by the guard itself.

The credential is minted once per process generation in the node runtime, rides the
runner's init message, and stays on the runtime handle as server-only state. It is
absent from `MiniAppPublishedView` and from every renderer contract, so no published
view, surface path or client payload can carry it.

Readiness stops meaning "the socket accepts": the runtime now requires an anonymous
health request to be rejected with 401 and the credentialed one to be answered 204,
which is what proves the guard is in the path rather than merely listening.

Still to do before #253 can be reviewed: the guard and validator test suites, and
the supervisor hand-off of the credential to the proxy that injects it.
…ntial

Closes #253.

A Mini App's port is derived from its plugin id, so a local process can compute it
rather than scan for it. Every `node:http` server the plugin creates is now
intercepted at the `http.Server.prototype.emit` seam before the entry module loads,
and only a request carrying the Host-issued credential on the exact loopback Host
reaches plugin code: anything else is answered 401 with no body, and an `upgrade` or
`connect` socket without it is destroyed. One reserved health path is answered 204
by the guard itself, so readiness can prove the guard is in the path rather than
merely that a socket accepts. The credential is stripped from both `headers` and
`rawHeaders` before plugin code runs, so plugin code never observes it.

The credential is minted once per process generation in the node runtime, before
the port-attempt loop, rides the runner's init message, and stays on the runtime
handle as server-only state. It is absent from `MiniAppPublishedView` and from every
renderer contract, so no published view, surface path or client payload can hold it.
Handing it to the proxy that injects it belongs to the proxy change, where its
consumer exists.

Readiness no longer means "the socket accepts": the runtime requires an anonymous
health request to be rejected with 401 and the credentialed one to be answered 204,
with `READINESS_PROBE_REJECTED` and `READINESS_PROBE_TIMEOUT` as the failure codes.

The suite drives a real `http.Server` through the real runner seam rather than a
mock: anonymous 401 with an empty body, wrong credential 401, right credential on
the wrong Host 401, the plugin handler reached with no credential header visible,
the health path answered without reaching the plugin, and an unauthenticated upgrade
destroyed while a credentialed one is forwarded. Six of the seven cases fail when
the guard install is removed.

Verified: pnpm typecheck; pnpm run test:capabilities (236 files, 5818 passed, 18
skipped); pnpm check:source after node scripts/source-inventory.mjs --write.
Readiness now lives in `loopback-readiness.ts` instead of inside `node-runtime.ts`,
so it can be exercised against a real server without faking a child process — and
so the proxy and ownership work reuse one definition of "the guard answered".

The module keeps the rule the guard exists for: an anonymous health request must be
rejected with 401 and the credentialed one answered 204, a port that answers wrongly
is not retried, and a stalled probe reports READINESS_PROBE_TIMEOUT.

Four cases added to the guard suite, all against real servers:

- the guarded fixture passes readiness
- a wrong credential fails with "expected 204, received 401"
- a plain server in a child process that answers 200 passes nothing: it fails with
  "expected 401, received 200", which is the case readiness exists to catch
- a closed port times out instead of reporting ready

Verified: pnpm typecheck; pnpm run test:capabilities (236 files, 5822 passed, 18
skipped); pnpm check:source after the inventory write.
The scripted-child harness `node-runtime` was missing: a `ChildProcess` stand-in that
runs the real runner in-process, so the readiness probe meets the real guard while the
child itself is scripted. With it, the credential stops being construction-only.

Two cases:

- one preparation mints exactly one credential, the init message carries it, and the
  runtime handle exposes the same value after readiness passed for it
- a second process generation receives a different credential, observed on the init
  message it was spawned with

Together with the guard suite this closes the last unproven acceptance criterion of
#253; the credential also stays out of every published view by type.

Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5824 passed, 18
skipped); pnpm check:source after the inventory write.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request miniapp WebUI Mini App hosting programme (ADR 0020)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant