Skip to content

Treat the server and the filesystem as untrusted input - #22

Merged
siliconwitch merged 1 commit into
mainfrom
dev
Aug 21, 2026
Merged

Treat the server and the filesystem as untrusted input#22
siliconwitch merged 1 commit into
mainfrom
dev

Conversation

@siliconwitch

Copy link
Copy Markdown
Member

The second of three cycles closing findings from the two architecture reviews. This one is a single idea applied at every boundary, which is why roughly two dozen sites are one pull request and not three.

The two functions

api.Printable escapes with Go's own rules through strconv.QuoteToGraphic, so it covers the control characters a terminal acts on and the bidirectional overrides that reorder what is around them, while leaving accented letters, other scripts, emoji and spaces byte-identical. api.Decode bounds a body at 32 MiB before reading it, set against the server's caps of 100 fleets per user and 100 fleet keys per fleet.

They live in internal/api for the same reason TakeJsonFlag and FormatBalance do: more than one part needs them and no part may call another. This is policy rather than procedure, and the failure mode of letting it drift apart at two dozen sites is a hole rather than a style wobble.

What was wrong

A device name is renamable by any member of the fleet, and neither side restricts the characters. A name carrying an erase-line sequence erased its own row and hid the device from the owner who pays for it.

Every success body was decoded unbounded while every error body was already bounded, and twelve sites returned encoding/json's own words to the user.

The same was true of the filesystem. Seven sites returned an os path error raw, and two fired after an irreversible act had already succeeded: deleting an account whose stored login could not then be removed swallowed Account deleted. entirely, leaving the user unable to tell whether the account was gone.

The stored login was written with os.WriteFile, which truncates first, so a failed write destroyed a working login, and 0600 applied only on creation, so a file already at a wider mode kept it.

Four smaller holes on the same theme: key create could print an empty fleet key and exit zero, logout looped forever on an empty stored login, one unreadable last-seen time hid the whole device table behind Go's layout string, and three fleet-name lookups rendered a blank cell for a fleet that had gone.

Evidence

  • gofmt -l . clean, go mod tidy leaves the module files unchanged, go vet ./... clean, go test -count=1 ./... all packages pass with no skips.
  • Every new guard was checked against the mutation that breaks it, on a copy of the tree. 15 of 16 caught.
  • Reproduced end to end against a stub: the erase-line name now prints as literal \x1b[2K\rhidden-from-the-owner, the row stays visible, and no live escape byte reaches stdout. A captive-portal HTML answer now reads as a sentence rather than invalid character '<'.
  • Printable cannot panic: strconv.QuoteToGraphic always returns a value quoted at both ends, checked against an empty string, a lone continuation byte, truncated multi-byte sequences and all-high bytes.
  • The credential write was verified at the primitive level too: os.CreateTemp creates at 0600, the temporary file shares the target's directory so the rename cannot cross a filesystem, and renaming over a pre-existing 0644 file narrows it to 0600 and leaves exactly one file behind.

Gaps, stated rather than softened

  • openBrowser's scheme check has no test. Exercising it would launch a real handler on the machine running the tests.
  • The 32 MiB constant is a judgement call. It is also the ceiling on how large a fleet the CLI can list, since the device list is the one response the server does not cap. CLAUDE.md records both.
  • CJK and emoji column alignment is unchanged and still skews, because rune count is not display width. That was a deliberate decision, not an oversight.

An adversarial review pass over this diff is still running. If it confirms anything, the fix lands as a second commit on this branch before merge.

No version change, and nothing under .github, flake.nix, .goreleaser.yaml or go.mod is touched. Paired with a documentation change in superstack-server.

Two functions in internal/api carry the policy, so it cannot drift apart at
the two dozen sites that need it. Printable escapes with Go's own rules, which
cover the control characters a terminal acts on and the bidirectional
overrides that reorder what is around them, while leaving accented letters,
other scripts, emoji and spaces byte-identical. Decode bounds a body at 32 MiB
before reading it, which is set against the server's caps of 100 fleets per
user and 100 fleet keys per fleet.

A device name is renamable by any member of the fleet, so a name carrying an
erase-line sequence used to erase its own row and hide the device from the
owner who pays for it. All five tables, both browser links, the one-time code,
the fleet key and the relayed server sentence now go through Printable.

Every success body was decoded unbounded while every error body was already
bounded, and twelve sites returned encoding/json's own words to the user. A
captive portal now reads as a sentence rather than "invalid character '<'".

The same was true of the filesystem. Seven sites returned an os path error
raw, and two of them fired after an irreversible act had already succeeded:
deleting an account whose stored login could not then be removed swallowed
"Account deleted." entirely. Both say what happened and what is left to do.

The stored login lands by rename from a temporary file in the same folder, so
an interrupted write cannot destroy a working login and a file already at a
wider mode cannot keep it.

Four smaller holes on the same theme: key create could print an empty fleet
key and exit zero, logout looped forever on an empty stored login rather than
treating it as logged out, one unreadable last-seen time hid the whole device
table behind Go's layout string, and three fleet-name lookups rendered a blank
cell for a fleet that had gone. account delete now reads the status of the
probe it already makes, so an invalid login is reported before the question
rather than after it.

Fifteen of the sixteen new guards were checked against the mutation that
breaks them. The exception is the scheme check in openBrowser, which cannot be
exercised without launching a real handler on the machine running the tests.

No version change, and nothing under .github, flake.nix, .goreleaser.yaml or
go.mod is touched.
@siliconwitch
siliconwitch merged commit ffb80af into main Aug 21, 2026
1 check passed
@siliconwitch
siliconwitch deleted the dev branch August 21, 2026 09:03
siliconwitch added a commit that referenced this pull request Aug 21, 2026
The third and last of the cleanup cycles, plus the five findings the
review of #22 confirmed.

## Coding rules

The `--json` path of every list command assigned nothing before it
returned, two commands built an HTTP client above the guards that can
refuse the call, and three constants had one use each. The two
five-second values in the login poll are literals now, each carrying the
RFC 8628 section it comes from. `Request` and `AuthenticatedRequest`
take a reader named `reader`; the session keeps `In` and `Out`, which
`docs/cli.md` records as the one place a reader is not called `reader`.

`takeServerFlag` had one production call site and was kept alive by a
unit test written against it, which is both of the rules that say not
to. Its thirty lines now run where they always ran, and the table that
covered them drives `Dispatch` instead: a synthetic command records the
base it was handed and the arguments left over.

## Tests

Nothing invoked `main`, so none of its exit paths were covered. A
subprocess table runs six of them, including the two that must exit
zero. `fleet list`, `key list` and `account balance` had no
server-refusal case, `FetchDevices` had no failure test of its own,
`member remove` never reached its unknown-fleet guard, and nothing read
the four lines `login` prints.

## Carried over from the review of #22

The `account delete` probe asked `GET /`, which
`superstack-server/internal/api/auth.go:25-29` exempts from
authentication, so the not-logged-in guard added last cycle could never
fire against the real server and its test built a server shape that does
not exist. It asks `GET /fleets` now. `logout` treated an
already-removed login as a failure while `account delete` tolerated it,
so a second logout printed an instruction naming a file that was not
there. `maximumBody` had one use. The comment on the bound named only
the device list as uncapped, and the member list is uncapped too. Two
error sentences said the login had "nowhere to live", which is a
metaphor that names no action.

## The flake guard

`CLAUDE.md` states that `vendorHash = null` holds only while the module
has no third-party dependencies, and nothing checked it: adding one
leaves `gofmt`, `go mod tidy` and the GoReleaser snapshot green while
`nix build` fails on inconsistent vendoring.

`TestTheModuleStaysDependencyFree` reads that condition out of `go.mod`
and `go.sum`, so it costs nothing and runs everywhere the suite does.
The new `flake` workflow covers what a Go build cannot see, the version
regex in `flake.nix` and the `postInstall` rename, by building the
package and checking that the version `main.go` declares, the version
the flake reads out of it, and the version the built binary reports all
agree.

It runs on tags and on pull requests touching `flake.nix`, `flake.lock`,
`main.go` or `go.mod`, not on every pull request, because a cold runner
downloads a 257 MiB Go toolchain to do it against a `build` job that
finishes in about a minute. Measured locally: 45 seconds of compile once
the store is warm. The paths list includes the workflow itself, so this
pull request runs it rather than merging it untested.

## Evidence

- `gofmt -l .` clean, `go mod tidy` leaves the module files unchanged,
`go vet ./...` clean, `go test -count=1 ./...` all packages pass with no
skips.
- Every new guard was checked against the mutation that breaks it. Two
came back missed on the first attempt and both were fixed rather than
reported as caught: the `fs.ErrNotExist` tolerance in `logout` had no
test, and the member-remove row served an empty fleet list so the loop
never ran under mutation.
- The workflow's script was run against a real build before being
written down. All three versions agreed and the binary landed at
`bin/superstack`, which is the rename working.
- The pinned action SHA was resolved with `gh api`, not guessed.
`actionlint`, which carries shellcheck, passes on all three workflows.
- A stray 10 MB `api.test` binary was sitting untracked in the tree, so
`*.test` is ignored now.

An adversarial review over this diff is still running. If it confirms
anything, the fix lands as a further commit on this branch before merge.

No version change, and nothing under `flake.nix`, `.goreleaser.yaml` or
`go.mod` is touched. Paired with a documentation change in
`superstack-server`.
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