Skip to content

fix: close the plugin findings of the S2 security review - #96

Merged
lr00rl merged 5 commits into
feature/20261010_s2-fleet-sourcesfrom
lane/20261016_s2-security-fixes
Oct 17, 2026
Merged

lr00rl merged 5 commits into
feature/20261010_s2-fleet-sourcesfrom
lane/20261016_s2-security-fixes

Conversation

@lr00rl

@lr00rl lr00rl commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the plugin findings of the S2 security review (design 28, review recorded in LatticeNet/lattice#23): S2-SR-1, the plugin part of S2-SR-2, and S2-SR-4, plus one sibling of S2-SR-1 the fix exposed. The server part of S2-SR-2 (a hash of the process chain in the plan section) and S2-SR-3 belong to the server lane; the contract this PR needs from core is at the end.

S2-SR-1 (High): writes that change what a fleet-bound collection gathers

writeContext.stage (system-go/store_guards.go) asked only whether the written record was itself fleet-bound, so a provider record that a fleet-bound collection gathers went live, and core serves such a collection's provider nodes to identity holders unvalidated. The staging decision now also stages when a collection that is fleet-bound over the union of live and staged state gathers the record at its live facts or at its new facts (gatheredByFleet, fleetGatherers). It covers save, import, migrate and restore, because they all go through decideWrite or writeContext.stage. deleteSubscription (system-go/store_archive.go) refuses a gathered record with fleet_delete_requires_plan and names the collections.

Sibling found while checking the fix against core: collectionParts (system-go/subscription_fleet_render.go) reused a provider member's snapshot block whenever the source matched, so core's preview of a collection with that member at its new staged revision still showed the live block. The plan's dependent section answered changed: 0 and the approver would approve a change the plan did not show. A provider block is now reused only when it was built at the revision the member renders at.

S2-SR-2 (Medium), plugin part: scripts on fleet-bound records

  • No script network: a render that reaches fleet nodes in any state (the record fleet-bound live, or fleet-bound over the union of live and staged facts with the rendered revision added, a file over either included), and the editor's fleet previews, run with the network withheld (system-go/fleet_script_network.go). withholdScriptHTTP (system-go/substore_engine.go) installs a gateway that sends nothing and counts every request (system-go/substore_script_http.go). The call then fails with fleet_script_network_unavailable, leading its message, even when the script caught the refusal, so a step that needed the network is never skipped silently. processNodes also withholds the network on its own, so no path that runs a script over fleet nodes can reach it. The whole render is withheld, a provider member's own chain inside a fleet-bound collection included, which matches fetch, where no chain has a network.
  • No line identity: processNodes (system-go/substore_engine_process.go) no longer sends a fleet node's _lattice block. It swaps every credential placeholder, which carries the line uuid, for an opaque per-call token (the same token for every node of a line), and swaps it back on return. Line recovery and the row's block are re-attached outside the script as before, so binding is unchanged. A provider node keeps only its random per-computation provenance mark, which names no line.
  • The UI knows the new code (ui/src/client.ts), so errorCodeOf recognises it.

S2-SR-4 (Low): migrate_record

migrate_record now declares vpncore:read in manifest.json, and the exemption in TestProxyMethodsDeclareVpncoreRead is gone. The test also lists migrate_record among the methods that must declare the scope.

Done

  • S2-SR-1 fix plus regression tests in system-go/store_fleet_gathered_test.go. These cover the reviewer's two probes (member edit, tag join) and one test per sibling write kind: tag added, tag removed, a record saved under an id a collection lists (how a rename lands, since membership follows ids), a member that stops being a sub, delete (explicit member and by tag), restore, and batch import. A negative case covers provider-only collections. With the fix reverted, the nine write cases fail (saved: true, staged: false, delete not refused).
  • Sibling fix in collectionParts, with TestFleetGatheredStagedMemberPreviewsItsStagedContent. It fails with the revision check removed, because the preview shows only the live provider node.
  • S2-SR-2 plugin part, with system-go/fleet_script_network_test.go. A recording http.do host asserts zero requests over fleet nodes for the serve-path render, the staged render core runs for a plan, a fleet-bound collection's own chain, and a provider member's chain inside one. Separate tests assert that the editor's preview shows the typed error, that processNodes withholds the network on its own, and that the script sees no _lattice block, placeholder, line uuid, line hash id, node id or row address while the plan still binds every node to its line. That last test runs one line per bind-shape protocol (vless, trojan, anytls, hysteria2, tuic, vmess, socks) and also fails with the placeholder masking alone removed. A provider-render control keeps its request. With the fix's two mechanisms reverted (the original node encoding in processNodes, and withholdScriptHTTP installing nothing), seven of the eight fail. Each render sends one request through the host, and the script reads the Lattice block.
  • TestFleetScriptStepRunsOnBundleProcess and TestInventedNodeInProviderCollectionIsNoLine were adapted: the first now forges a _lattice block outright, and the second finds a fleet node as the one without a provider mark.
  • S2-SR-4. TestProxyMethodsDeclareVpncoreRead fails with the manifest change reverted and passes with it. The manifest tests pass, and lattice-plugin-manifest-check at the CI pin accepts the manifest.
  • Local runs: go test ./... in system-go, -race on the touched tests (no DATA RACE), and the UI vitest run and vue-tsc all pass.
  • CI on 0366592 (run 38981661126): conformance, memory, fuzz, perf and ui-e2e pass. In verify, every step through "assemble UI bundle tree" passes, including tests, vet, govulncheck, the UI suite and the manifest check; only the expected "package twice and compare bytes" step fails, because the digest pin moves only at the release commit. Each earlier commit's run failed at that same step and nowhere else.
  • Checked against lane 7 in a local merge that was never pushed (this branch plus lane/20261016_s2-gates at 3eb17e1). There the plugin suite passes, TestCredentialAbsenceSweep included. Lane 7's server (lattice-server 42853c1) was pinned at that merge, and its 17 S2 end-to-end tests all pass with no skips, TestScriptOperatorForeignServerIsPlanRejected among them. A throwaway end-to-end check on that server confirmed the core path. A provider member edit stages, plans.publish refuses it, propose requires a plan whose dependent section shows the provider-content change, and approval serves it. A direct delete of a member gathered by tag answers fleet_delete_requires_plan, and the delete plan removes it.

Not done

  • Server part of S2-SR-2 (chain hash in the plan section) and S2-SR-3: server lane.
  • Core does not yet show the new code in the plan view (contract below). Today plans.propose answers bind preview failed for revision ... and identity ... and bind.preview answers the record did not render, both checked on 42853c1.
  • Dev harness ui/dev/fleetHost.ts fleetWithheld does not yet drop migrate_record for ?fleet=denied. Left for lane 6b, whose migration flow is the consumer.

Contract for core and the UI

  • Render, preview and preview_draft errors for this case lead with fleet_script_network_unavailable: , meaning the code, a colon, then a space, the same shape as the other S2 read-method codes. Everything after the colon is plugin text, which names the record and a request count and never what the script sent. For the plan view, core can lift a leading code that matches a fixed allowlist into the propose and bind.preview error, and relay nothing else of the plugin's message. No new field is added to the render reply or the plan.
  • A provider record can now answer staged: true on save and fleet_delete_requires_plan on delete although its list flags show it is not fleet-bound. The UI should offer the plan flow from the reply, not from the flag. Core needs no change for this: depends_on already reports the dependents.
  • Without vpncore:read, a token no longer sees migrate_record in the handshake.

…hers

The staging decision asked only whether the written record was itself
fleet-bound before or after the write. A provider record that a fleet-bound
collection gathers, as an explicit member or by a member tag, is not
fleet-bound, so an edit of it, a new record carrying a member tag, or a tag
added to or dropped from a live record took the live branch. Core serves a
fleet-bound collection's provider nodes to every identity holder without
validating them, so the next render handed the new nodes out with no plan
(S2-SR-1).

writeContext.stage now also stages when a collection that is fleet-bound
over the union of live and staged state gathers the record at its live
facts or at its new facts. That one rule covers a member edit, a tag join,
a tag added or removed, a new or restored record under an id a collection
already lists (a rename lands that way, since membership follows ids), a
member that stops being a sub, and batch writes from import and migrate.
deleteSubscription refuses such a record with fleet_delete_requires_plan
and names the collections, as it does for a fleet-bound record. Core's
closure and plan need no change: depends_on already reports the dependents
and the plan already folds provider content.

Rejected: staging whenever the record has a pending staged revision. It
would stage live edits of a legacy record whose migration is staged, which
the editor depends on.

Tested: the nine new TestFleetGathered* write cases fail with the fix
reverted (saved=true staged=false, delete not refused) and pass with it;
go test ./... in system-go passes.
…dentity

The script network is granted per method, and render carries it, so a
Script Operator or Resolve Domain step on a fleet record or a fleet-bound
collection ran with the network while processNodes handed it every node
with its Lattice block (node id, line hash id, groups, chain, addresses) and
a credential placeholder that names the line. The author needs only
substore:admin, which does not carry vpncore:read, and the step also runs
when core previews a staged revision for a plan, before approval (S2-SR-2).

A render that reaches fleet nodes in any state (the record fleet-bound
live, or over the union of live and staged facts with the rendered
revision added) and the editor's fleet previews now run with the network
withheld: withholdScriptHTTP installs a gateway that sends nothing and
counts every request, and the call fails with
fleet_script_network_unavailable, leading its message, whenever a script
asked, even if the script caught the refusal. processNodes withholds it on
its own as well, so no path that runs a script over fleet nodes can reach
the network. The whole render is withheld, including a provider member's
own chain inside a fleet-bound collection, which matches fetch, where no
chain has a network.

processNodes no longer sends a fleet node's Lattice block, and it swaps
each credential placeholder for an opaque per-call token, the same token
for every node of a line, and swaps it back on return. Line recovery and
the row's block are re-attached outside the script exactly as before, so
binding is unchanged. A provider node keeps only its per-computation
provenance mark, which names no line.

The UI learns the new code so errorCodeOf recognises it.

Tested: the seven new fleet script network tests fail with the fix's two
mechanisms reverted (one request sent through the host per render, the
script reads the Lattice block) and pass with it; the provider render
control keeps its request. go test ./... in system-go, -race on the touched
tests, and the UI unit tests and typecheck pass.
migrate_record reads the catalogue and its reply warns whether a pinned
root is in the catalogue and whether a name-based step matches any line
label. A caller with substore:admin and no vpncore:read could ask one
question per call about line labels and line identifiers (S2-SR-4). The
migration flow relies on those warnings, so the method keeps them and
declares the scope instead, and the exemption in
TestProxyMethodsDeclareVpncoreRead goes. The test now also names
migrate_record among the methods that must declare the scope.

A token without vpncore:read no longer sees migrate_record in the
handshake.

Tested: TestProxyMethodsDeclareVpncoreRead fails with the manifest change
reverted and passes with it; the manifest tests pass and
lattice-plugin-manifest-check at the CI pin accepts the manifest.
collectionParts reused a provider member's snapshot block whenever the
block's source matched, whatever revision the member rendered at. Before
S2-SR-1 a provider member never had a staged revision of the same source,
so that was safe. Now every edit of a member a fleet-bound collection
gathers stages, and core previews the collection with that member named
at its staged revision. The preview took the live block, the plan's
dependent section answered added 0, changed 0, and the approver approved a
change the plan did not show; the next fetch then served it.

A provider block is reused only when it was built at the revision the
member renders at. A member at a staged revision resolves its staged
content live, as a member switched from fleet to provider already did. A
member at its live revision still reuses its block, so core's byte-for-byte
comparison at the decision is unchanged for it.

Tested: TestFleetGatheredStagedMemberPreviewsItsStagedContent fails with
the revision check removed (the preview shows the live provider node only)
and passes with it; the staged collection render tests pass.
TestFleetScriptSeesNoLineIdentity covered vless only, whose placeholder
sits in uuid. It now renders one line per bind-shape protocol (vless,
trojan, anytls, hysteria2, tuic, vmess, socks), so a placeholder in
password or username, or one kept in a socks userinfo, would reach the
script and fail the test. With the masking alone removed the test fails.
@lr00rl
lr00rl merged commit 82d7a34 into feature/20261010_s2-fleet-sources Oct 17, 2026
5 of 6 checks passed
@lr00rl
lr00rl deleted the lane/20261016_s2-security-fixes branch October 17, 2026 21:05
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