Repository navigation
fix(security): Attach asks whether the instance and the network are ours (#909) - #931
Merged
Merged
Conversation
…urs (#909) Attach checked the shape of its two names and nothing else, where Detach asks both questions. Both names come from Resource.Runtime, which PUT /_feint/state and snapshot load restore verbatim, so a crafted snapshot added a NIC to an instance of the host. Attach now requires ownedNetwork on the network and mustOwnInstance on the machine, in that order after the shape checks and before the agent probe, so nothing reaches a foreign instance. Unlike Detach, a missing instance is an error: answering nil would let the caller publish an address for nothing. Proven through the injectable runner only; no machine was started. TestAttachRefusesAnInstanceItDoesNotOwn, TestAttachRefusesANetworkOutsideThePrefix and TestAttachStillReachesAnInstanceTheEmulatorCreated are the guard and its accepting half; tools/falsify/specs/attach-asks-both-questions.json shows each turns red. Two older specs had their fragments retargeted around the new lines. Assisted-by: Claude Code (claude-opus-5)
This was referenced Nov 14, 2026
… the machine (#909) TestAttachRefusesANetworkOutsideThePrefix passed against a guard that reads the wrong name. Every row drove the machine `feint-scw-mine`, which ownedNetwork also refuses, so `ownedNetwork(name)` in place of `ownedNetwork(att.Network)` still refused every row and the test stayed green. The shape check beside it still reads att.Network, so the line keeps looking like a guard. A falsifier pass measured that, on the lot as pushed: the defect was caught only by TestAttachStillReachesAnInstanceTheEmulatorCreated, the accepting half, and by neither attack test. A guard whose attack test cannot tell it from a copy-paste is held by one test. The table now carries the machine name, and the last row gives it a name ownedNetwork accepts (`fnt-mine`), so a guard reading the machine accepts that row and the attach goes through. attach-asks-both-questions.json gains the mutation: six now, all biting, verified after restoration. Assisted-by: Claude Code (claude-opus-5)
This was referenced Nov 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Attachasked one of the two questions this layer has to ask. It checked thatits machine name and its network name were well formed (
safeName) andstopped there.
Detach, three hundred lines away, asks both.Both names come from
Resource.Runtime.PUT /_feint/stateandfeint snapshot loadrestore that map verbatim, andinternal/cli/snapshot.godocuments the format as meant to outlive its instance and be loaded into
another one. So a crafted snapshot naming an instance of the operator reached:
production-databasepassessafeName. That is the whole point of therepository's "bien formé n'est pas autorisé" rule: a syntax check was standing
where an ownership check belonged, and this is the third time that exact
substitution has been found here.
The change
internal/core/machine/incus.go, inAttach:ownedNetwork, not merelysafeName;d.mustOwnInstance(ctx, name), which reads theuser.feint.providerlabel the emulator itself wrote at create;waitForAgent, soAttachsends nothing to a foreign instance.One word of precision, because the first draft of this overstated it: that is
true of
Attach, not of the whole join.Reconciler.Joinruns itsread-back whether the attach was refused or not, and
Observestill execs intothe instance without asking ownership. That is #920, already open, and it is
not closed by this lot.
One deliberate difference from
Detach: a missing instance is an error here,not nil. A detach of a machine that is gone has nothing left to undo. An
attach that answered nil would let the caller go on and publish an address for
a machine that does not exist.
Type of change
The tests, and the accepting half
TestAttachRefusesAnInstanceItDoesNotOwnTestAttachRefusesANetworkOutsideThePrefixTestAttachStillReachesAnInstanceTheEmulatorCreatedThe third one is not decoration. A guard that refuses everything passes every
attack test and breaks the product, so the suite asserts the accepting half
explicitly.
All three go through the injectable
runner, which recordsargv. That iswhat lets a test assert the property that actually matters: no emitted
command carries the foreign name. No machine was started to prove any of
this.
Runs
main(ce59308, after #929)e97ca46, diff unchangedmise run prepushmise run testplannamesFEINT_VM=incus-ovn mise run conformance:leg -- runtimefnt-network, no surviving DHCPMode, stated because a blank cell has been read as "not asked" before:
incus-ovn, on the maintainer's station, checked free first (free -g,incus list) with no labs running.The leg's own closing checks, from its log:
no machine or network of this run is left on this runtime,nothing on this runtime is beyond an ordinary sweep,no DHCP service of this emulator's outlives its network on this host.On the leg's cost
CLAUDE.mdstill announces 590 s for this leg. Two independent measurements aday apart give 1433 s (2026-11-13) and 1435 s (2026-11-14). The gap is
not noise, and part of it is identified: six machine starts each wait 90 s for
a resolver the guest never names (
incus_resolver.go), which is nine minutesof expiring waits the leg passes without. Recorded here, not fixed here.
Falsification
tools/falsify/specs/attach-asks-both-questions.json, new, shows each of thethree tests turns red without the guard it holds. Two older specs,
attach-per-machine.jsonandrun-leaves-nothing.json, had theirfindfragments retargeted around the new lines; both replay green in the 16 above.
Runs not played
The full
mise run conformancepass. Theruntimeleg carries the four suitesa driver change touches, which is the row
mise run testplannames, and it ranunder the mode that can actually prove isolation. What the full pass would add
here is the client suites, which this diff does not reach.
Checklist
Always
mise run checkpasses (insideprepush, on the rebase)internal/corestill knows no provider.mustOwnACLreads a descriptionrather than a name precisely so no provider spelling enters the core, and
this change keeps that
the guard is at the shared choke point, so all three packs get it at once
mise run testplanwas run, and what it printed was run. The one run notplayed is named above
When a model wrote a substantive part of this
Assisted-by:trailer names the tool and the model versionWhen a route is added or changed
N/A.
When behaviour a client can observe changes
N/A. A refusal that was already supposed to happen now happens. No documented
limit moves.
When
.github/workflows/is touchedN/A.
When a machine runtime is involved
FEINT_VM=incus-ovnthe change, and the
runnertests assert it atargvlevelincus listandincus network listafter the leg, plus the leg's own three closing verdictsReview
Security review: reported, no blocker on this commit.
Attachis closed.The order was checked call by call: nothing is emitted before the ownership
probe, the check sits inside the lock that protects the action it guards, and a
failed probe fails closed (daemon down, timeout and permission denied all
refuse, and
isNotFounddeliberately carries no bare "not found" that woulddegrade them to permissive).
It also confirmed that the guard's own input is not attacker-writable: instance
labels are written only at
incus init, from a derived name, and theexisting-instance branch of
Startwrites no config, so there is no adoptionpath.
It found three sibling paths this lot does not touch. Each has a home
rather than a sentence here:
incus.go:508and
incus.go:704accept any well-formed network name from a restoredRuntime. Measured through the runner:init ... --network incusbr0andconfig device add ... network=incusbr0. That is the boot path, so it runsmore often than the hot attach this lot fixes.
RouteAddress's managed branch asks the network and never theinstance it writes to. Its own comment says so, which is the defect.
ObserveandDoor.#932 is the reason to read this pull request narrowly: it closes
Attach, notthe attach family.
Falsifier: reported, no blocker. Nine mutations measured, each built and
vet-clean, baseline green and "after restoration: green" in every pass.
It found one real weakness in this lot, and
ad6e6fbfixes it:TestAttachRefusesANetworkOutsideThePrefixstayed green againstownedNetwork(name)in place ofownedNetwork(att.Network), because every rowdrove the machine
feint-scw-mine, whichownedNetworkalso refuses. Therefusal happened, for the wrong reason, and only the accepting half caught the
defect. The table now carries the machine name and its last row gives it one
ownedNetworkaccepts. The mutation was added to the spec, which is now six,all biting; the reviewer re-measured on
ad6e6fband confirms it bites thereand did not on
e97ca46.Two findings outside this lot, each with its own issue:
taken under. Measured over the whole package, a completed run with no
timeout: no test catches the check moving out of the lock, where all five
other plausible defects in this guard are caught. Not a vulnerability today
(nothing relabels an existing instance) and not a broken promise (neither the
commit nor the comment claims the lock placement). That is the issue: the
property holds by accident of what no other path does.
incus_start_race_test.gowait on a bare channelwith no deadline, so a guard that refuses before the device add makes them
hang rather than fail. It truncated three of the reviewer's own
measurements, which is the harness failing before the subject.
One measurement-integrity note the review raised, worth recording: the shared
test fake answers
"feint\n"to any ownership probe by default(
incus_firewall_test.go:53). This lot's tests override it, so they are sound.The next ownership guard somebody writes will look tested when it is not.
Related issues
Closes #909.
🤖 Generated with Claude Code