a73x

7c233ac3

security: a stranger on the internet is given less to work with

a73x   2026-08-22 09:23

Commit message
security: a stranger on the internet is given less to work with

The sync transport drops a panic any host could reach before authenticating:
quic-go queued undecryptable packets after a handshake completed, and both
planes complete a handshake with an unenrolled client. The jump gate bounds
how many logins may be in flight at once, so the work a nameless connection
can compel is capped rather than unbounded, and it records who reached which
VM — the serial and key id ride the certificate, so an audit row names the
identity rather than an address.

Browser responses now carry the headers that constrain them. The policy pins
the SPA's inline entry point by hash rather than conceding 'unsafe-inline':
with a hash present a browser ignores that keyword, so the console still boots
while injected script is refused. Framing is denied twice over, referrers are
trimmed cross-origin, sniffing is off, and HSTS is sent only once the browser
is already on TLS.

A revocation binds the tenant that wrote it. Keyed on serial alone the table
failed OPEN: the first tenant to revoke a serial took the row and a second
tenant revoking the same serial was silently dropped, its certificate still
good. A session row stops being a credential — the id is stored hashed, so
reading the table no longer yields a token that can be replayed.

CI gates on what it can prove. govulncheck fails the build on a vulnerability
the code actually calls, images are scanned before they are published, and the
nightly backup carries sqlite in its image instead of fetching it from a
package CDN on the critical path of the one database nobody can rebuild.

Two assumptions are written down rather than left implied: a credential age
limit is a deadline and not a rotation, and a guest keeps the host certificate
it was born with for as long as it lives.

Makefile
Old New
@@ -6,14 +6,19 @@ GOLANGCI_VERSION := v2.13.0
6 GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint 6 GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint
7 # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately. 7 # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately.
8 DEADCODE_VERSION := v0.48.0 8 DEADCODE_VERSION := v0.48.0
9 # Pinned vulnerability scanner (golang.org/x/vuln/cmd/govulncheck). Bump
10 # deliberately — and note the floor is real: v1.1.4 and older crash under
11 # Go 1.27's module graph.
12 GOVULN_VERSION := v1.7.0
13 GOVULN := $(shell go env GOPATH)/bin/govulncheck
9 # Style/complexity linters run informationally (exit 0); promote into 14 # Style/complexity linters run informationally (exit 0); promote into
10 # .golangci.yml's enable list once a linter's baseline is clean. 15 # .golangci.yml's enable list once a linter's baseline is clean.
11 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret 16 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret
12 17
13 .PHONY: build build-go build-darwin web test vet proto clean \ 18 .PHONY: build build-go build-darwin web test vet proto clean \
14 lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \ 19 lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \
15 site site-check ci deadcode web-test web-check \ 20 site site-check ci deadcode web-test web-check vuln vuln-tool scan-image \
16 deploy release ship hooks site-image server-image 21 deploy release ship hooks site-image server-image backup-image
17 22
18 # Enable the repo's client-side merge gate: point git at .githooks, whose 23 # Enable the repo's client-side merge gate: point git at .githooks, whose
19 # pre-push hook runs `make ci` before any push that updates main. Run once per 24 # pre-push hook runs `make ci` before any push that updates main. Run once per
@@ -100,6 +105,31 @@ lint: lint-tool
100 lint-extra: lint-tool 105 lint-extra: lint-tool
101 $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./... 106 $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./...
102 107
108 .PHONY: vuln-tool
109 vuln-tool:
110 @$(GOVULN) -version 2>/dev/null | grep -q "$(GOVULN_VERSION:v%=%)" || \
111 go install golang.org/x/vuln/cmd/govulncheck@$(GOVULN_VERSION)
112
113 # Known-vulnerability gate over the Go dependency graph.
114 #
115 # It is REACHABILITY-based, which is what makes it usable as a gate: an
116 # advisory in a module we never call the vulnerable symbol of does not fail the
117 # build, so this stays quiet until something we actually execute is affected —
118 # and then it names the call site. That is the difference between a gate people
119 # keep and one they learn to skip.
120 #
121 # It covers Go modules only. The OS layer of a published container is a
122 # different surface with a different scanner; see scan-image.
123 vuln: vuln-tool
124 $(GOVULN) ./...
125
126 # Scan one already-built image for OS/library advisories (see
127 # scripts/scan-image.sh). The image publish scripts call this between build and
128 # push; this target is for scanning something by hand.
129 # make scan-image IMAGE=registry.example/eitri-site:v0.0.7
130 scan-image:
131 ./scripts/scan-image.sh $(IMAGE)
132
103 # Per-package coverage ratchet (see scripts/coverage.sh). 133 # Per-package coverage ratchet (see scripts/coverage.sh).
104 cover: 134 cover:
105 ./scripts/coverage.sh 135 ./scripts/coverage.sh
@@ -209,6 +239,11 @@ site-image:
209 server-image: web 239 server-image: web
210 ./scripts/server-image.sh 240 ./scripts/server-image.sh
211 241
242 # The nightly backup job's image: alpine + sqlite, carrying its own tag. Built
243 # only when the base moves, not per release.
244 backup-image:
245 ./scripts/backup-image.sh
246
212 # Console logic under test (web/src/**/*.test.ts, run by vitest). New logic in 247 # Console logic under test (web/src/**/*.test.ts, run by vitest). New logic in
213 # the SPA lands with tests; markup does not need them. 248 # the SPA lands with tests; markup does not need them.
214 # 249 #
@@ -270,7 +305,7 @@ deadcode:
270 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative 305 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative
271 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` 306 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch`
272 # re-runs the fitness tests with -count=1 (the race run may serve them cached). 307 # re-runs the fitness tests with -count=1 (the race run may serve them cached).
273 ci: vet build-go build-darwin arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check web-test web-check 308 ci: vet build-go build-darwin arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check web-test web-check vuln
274 309
275 # Compile every Go package (no Node/web build needed — the embed dir ships a 310 # Compile every Go package (no Node/web build needed — the embed dir ships a
276 # placeholder, so the server builds and serves a "UI not built" notice). 311 # placeholder, so the server builds and serves a "UI not built" notice).
deploy/server/README.md
Old New
@@ -574,6 +574,18 @@ The nightly CronJob writes dated sqlite backups onto prod's PVC; run
574 not survive the node, so the off-node copy is the DR story. stg has no CronJob: 574 not survive the node, so the off-node copy is the DR story. stg has no CronJob:
575 its database is disposable by design. 575 its database is disposable by design.
576 576
577 The job runs `$BACKUP_IMAGE` — alpine with sqlite baked in, built by
578 `make backup-image` (see `backup.Dockerfile`). It carries its OWN tag and is not
579 rebuilt per release: it holds sqlite and nothing of eitri's. Set `BACKUP_IMAGE`
580 in the plane's `ship.env` where `BACKUPS=1`; ship refuses to render without it.
581
582 It is a separate image because the job used to `apk add sqlite` on every run,
583 which put a package CDN on the critical path of the only protection a database
584 nobody can rebuild has. Fetch failures took whole nights of backups with them
585 and left nothing but a CronJob pod in Error — which is worth knowing about:
586 nothing alerts on a failed backup, so a run of bad nights is silent until a
587 restore needs one.
588
577 A restore needs the plane's `key_encryption_key` as well as its data. The host 589 A restore needs the plane's `key_encryption_key` as well as its data. The host
578 CA on the PVC is sealed under it, so data alone rebuilds a plane that cannot 590 CA on the PVC is sealed under it, so data alone rebuilds a plane that cannot
579 open it — it would come back without the identity every client pins. Your `~/eitri-deploy/<target>/server.json` is 591 open it — it would come back without the identity every client pins. Your `~/eitri-deploy/<target>/server.json` is
deploy/server/backup-cronjob.yaml
Old New
@@ -18,12 +18,14 @@ spec:
18 kubernetes.io/hostname: ${NODE_NAME} 18 kubernetes.io/hostname: ${NODE_NAME}
19 containers: 19 containers:
20 - name: backup 20 - name: backup
21 image: alpine:3.20 21 # sqlite is baked in (deploy/server/backup.Dockerfile). It used to
22 # be installed on every run, which put a package CDN on the
23 # critical path of the only copy of a database nobody can rebuild.
24 image: ${BACKUP_IMAGE}
22 command: ["/bin/sh", "-c"] 25 command: ["/bin/sh", "-c"]
23 args: 26 args:
24 - | 27 - |
25 set -e 28 set -e
26 apk add --no-cache sqlite
27 mkdir -p /data/backups 29 mkdir -p /data/backups
28 sqlite3 /data/eitri.db ".backup /data/backups/eitri-$(date +%F).db" 30 sqlite3 /data/eitri.db ".backup /data/backups/eitri-$(date +%F).db"
29 find /data/backups -name 'eitri-*.db' -mtime +14 -delete 31 find /data/backups -name 'eitri-*.db' -mtime +14 -delete
deploy/server/backup.Dockerfile
Old New
@@ -0,0 +1,19 @@
1 # The nightly backup job's image: alpine with sqlite already in it.
2 #
3 # The job used to run `apk add --no-cache sqlite` on every execution, which put
4 # the Alpine CDN on the critical path of the one thing that protects a database
5 # nobody can rebuild. A transient fetch failure took the night's backup with it,
6 # and the only trace was a CronJob pod in Error that nothing was watching.
7 #
8 # Nothing here is versioned against a release: the job runs one sqlite command
9 # and does not change when eitri does, so this image carries its own tag and is
10 # rebuilt only when the base moves.
11 # 3.24, not the 3.20 the job inherited: 3.20 is past end-of-life, so it receives
12 # no further security fixes and the image scanner's --ignore-unfixed would then
13 # quietly pass anything found in it. Bump when this line goes EOL too.
14 FROM docker.io/library/alpine:3.24
15
16 RUN apk add --no-cache sqlite
17
18 # Backups are written to a mounted PVC as root; the job creates /data/backups.
19 ENTRYPOINT ["/bin/sh", "-c"]
deploy/server/config.required
Old New
@@ -69,6 +69,7 @@ oidc.allowed_identities optional
69 # Absent means the built-in default (eitri.sh); an explicit empty string 69 # Absent means the built-in default (eitri.sh); an explicit empty string
70 # disables release discovery and every upgrade surface with it. 70 # disables release discovery and every upgrade surface with it.
71 release_manifest_url optional 71 release_manifest_url optional
72 log_level optional
72 credential_max_age optional 73 credential_max_age optional
73 # How long the append-only audit log keeps a row. Absent means 90 days; "0" 74 # How long the append-only audit log keeps a row. Absent means 90 days; "0"
74 # keeps every row forever. 75 # keeps every row forever.
docs/assumptions.md
Old New
@@ -647,3 +647,29 @@ renewal.
647 Falsified by an operator pointing the default image at a non-netplan distro, 647 Falsified by an operator pointing the default image at a non-netplan distro,
648 and quietly: both NICs still address, the guest still boots, and nothing in 648 and quietly: both NICs still address, the guest still boots, and nothing in
649 eitri reads a route table to notice. 649 eitri reads a route table to notice.
650
651 ### An operator will notice a leaked host credential and revoke it
652
653 A host credential is minted once at enrollment and never re-issued, so the only
654 bound on a stolen one is someone calling revoke-credential on that host. The
655 age limit that would bound it automatically (`credential_max_age`) is a deadline
656 rather than a rotation — nothing renews, so every host it applies to goes dark
657 and needs re-enrolling by hand — which is why it ships unset.
658 **Not proven**, and the weakest link in host authentication: it assumes
659 detection. Renewal on the sync channel is what would replace the assumption
660 with a mechanism.
661
662 ### A guest keeps the host certificate it was born with, for as long as it lives
663
664 The certificate reaches a guest exactly once, in the cloud-init seed built at
665 create (`internal/agent/reconcile`), and converge never rebuilds a seed — so
666 eitri has no way to hand a running guest a new one. That is what "guest owns the
667 guest" costs here, and it is why the certificate's ten-year window is deliberate
668 rather than lazy: any shorter TTL is a date on which every guest older than it
669 becomes unverifiable, with recreate as the only remedy.
670 **Proven**: traced in code — `e.Seed` has one call site, inside create, and
671 cloud-init runs once per instance-id. The consequence is the open one: a
672 certificate for `<tenant>.<name>` outlives the VM it was issued to, and VM names
673 are reusable, so deleting a VM does not retire its identity. Bounding that needs
674 a delivery channel for a replacement certificate, which does not exist yet.
675
go.mod
Old New
@@ -10,13 +10,13 @@ require (
10 github.com/lima-vm/go-qcow2reader v0.7.1 10 github.com/lima-vm/go-qcow2reader v0.7.1
11 github.com/modelcontextprotocol/go-sdk v1.6.1 11 github.com/modelcontextprotocol/go-sdk v1.6.1
12 github.com/pkg/sftp v1.13.11 12 github.com/pkg/sftp v1.13.11
13 github.com/quic-go/quic-go v0.48.2 13 github.com/quic-go/quic-go v0.49.1
14 github.com/stretchr/testify v1.11.1 14 github.com/stretchr/testify v1.11.1
15 github.com/yuin/goldmark v1.8.4 15 github.com/yuin/goldmark v1.8.4
16 golang.org/x/crypto v0.54.0 16 golang.org/x/crypto v0.54.0
17 golang.org/x/net v0.56.0 17 golang.org/x/net v0.56.0
18 golang.org/x/oauth2 v0.36.0 18 golang.org/x/oauth2 v0.36.0
19 golang.org/x/sync v0.20.0 19 golang.org/x/sync v0.22.0
20 golang.org/x/sys v0.47.0 20 golang.org/x/sys v0.47.0
21 golang.org/x/term v0.45.0 21 golang.org/x/term v0.45.0
22 google.golang.org/protobuf v1.36.11 22 google.golang.org/protobuf v1.36.11
@@ -31,9 +31,7 @@ require (
31 github.com/dustin/go-humanize v1.0.1 // indirect 31 github.com/dustin/go-humanize v1.0.1 // indirect
32 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect 32 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
33 github.com/go-jose/go-jose/v4 v4.1.4 // indirect 33 github.com/go-jose/go-jose/v4 v4.1.4 // indirect
34 github.com/go-logr/logr v1.4.3 // indirect
35 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 34 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
36 github.com/golang/protobuf v1.5.4 // indirect
37 github.com/google/jsonschema-go v0.4.3 // indirect 35 github.com/google/jsonschema-go v0.4.3 // indirect
38 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect 36 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
39 github.com/google/uuid v1.6.0 // indirect 37 github.com/google/uuid v1.6.0 // indirect
@@ -53,7 +51,7 @@ require (
53 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect 51 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
54 github.com/ulikunitz/xz v0.5.15 // indirect 52 github.com/ulikunitz/xz v0.5.15 // indirect
55 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 53 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
56 go.uber.org/mock v0.4.0 // indirect 54 go.uber.org/mock v0.5.0 // indirect
57 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 55 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
58 golang.org/x/mod v0.33.0 // indirect 56 golang.org/x/mod v0.33.0 // indirect
59 golang.org/x/tools v0.42.0 // indirect 57 golang.org/x/tools v0.42.0 // indirect
go.sum
Old New
@@ -17,16 +17,16 @@ github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxN
17 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= 17 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
18 github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= 18 github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
19 github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 19 github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
20 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 20 github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
21 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
22 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 22 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
23 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 23 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
24 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 24 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
25 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 25 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
26 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 26 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
27 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 27 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
28 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 28 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
29 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 29 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
30 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
31 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
32 github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= 32 github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@@ -71,8 +71,8 @@ github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
71 github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= 71 github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
72 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 72 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
73 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
74 github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE= 74 github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0=
75 github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= 75 github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s=
76 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 76 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
77 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 77 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
78 github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= 78 github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
@@ -93,8 +93,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
93 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 93 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
94 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= 94 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
95 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 95 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
96 go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 96 go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
97 go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 97 go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
98 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= 98 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
99 golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= 99 golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
100 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 100 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
@@ -105,8 +105,8 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
105 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 105 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
106 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 106 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
107 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 107 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
108 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 108 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
109 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 109 golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
110 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
111 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
112 golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/server/api/isolation_test.go
Old New
@@ -292,14 +292,14 @@ func TestSSHCertRevokeRejectsForeignTenantCert(t *testing.T) {
292 // default tries to revoke beta's cert → 404, and the serial stays live. 292 // default tries to revoke beta's cert → 404, and the serial stays live.
293 resp := do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": line}) 293 resp := do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": line})
294 assert.Equal(t, 404, resp.StatusCode, "revoking another tenant's cert must 404") 294 assert.Equal(t, 404, resp.StatusCode, "revoking another tenant's cert must 404")
295 revoked, err := w.st.IsSSHCertRevoked(cert.Serial) 295 revoked, err := w.st.IsSSHCertRevoked("beta", cert.Serial)
296 require.NoError(t, err) 296 require.NoError(t, err)
297 assert.False(t, revoked, "a cross-tenant revoke must not have taken effect") 297 assert.False(t, revoked, "a cross-tenant revoke must not have taken effect")
298 298
299 // beta revokes its own cert → 204, and it shows only in beta's list. 299 // beta revokes its own cert → 204, and it shows only in beta's list.
300 resp = do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", w.betaPAT, map[string]any{"certificate": line}) 300 resp = do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", w.betaPAT, map[string]any{"certificate": line})
301 require.Equal(t, 204, resp.StatusCode) 301 require.Equal(t, 204, resp.StatusCode)
302 revoked, err = w.st.IsSSHCertRevoked(cert.Serial) 302 revoked, err = w.st.IsSSHCertRevoked("beta", cert.Serial)
303 require.NoError(t, err) 303 require.NoError(t, err)
304 assert.True(t, revoked) 304 assert.True(t, revoked)
305 305
internal/server/api/sshcert.go
Old New
@@ -50,11 +50,14 @@ func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) {
50 // registered CA, revoking is a cross-tenant act and answers 404 (no existence 50 // registered CA, revoking is a cross-tenant act and answers 404 (no existence
51 // leak). Otherwise (the caller's own CA, or an unregistered CA that is nobody's) 51 // leak). Otherwise (the caller's own CA, or an unregistered CA that is nobody's)
52 // the revocation is filed under the CALLER's tenant. The bare-SERIAL form has no 52 // the revocation is filed under the CALLER's tenant. The bare-SERIAL form has no
53 // CA to resolve, so it is always filed under the caller's tenant — but note the 53 // CA to resolve, so it is always filed under the caller's tenant.
54 // ROW is what's namespaced; gate enforcement stays fleet-wide by serial 54 //
55 // (fail-safe, deny-only), so revoking a serial denies it for everyone. Serials 55 // That filing is now the whole story, because ENFORCEMENT is scoped to it: the
56 // are 64-bit crypto-random and never exposed cross-tenant, which is what keeps 56 // gate resolves a presented certificate to the tenant that registered its
57 // that acceptable; scoping gate enforcement per-tenant is a recorded follow-up. 57 // signing CA and consults only that tenant's rows. So a revocation can never
58 // reach past the caller, and a bare serial the caller does not own denies
59 // nothing. It used to deny that serial for the entire fleet — deny-only and so
60 // fail-safe for the guest, but an availability hole for every other tenant.
58 // The revocation LIST is tenant-scoped. 61 // The revocation LIST is tenant-scoped.
59 func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { 62 func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
60 var req types.RevokeSSHCertRequest 63 var req types.RevokeSSHCertRequest
internal/server/api/sshcert_test.go
Old New
@@ -86,7 +86,7 @@ func TestSSHCertRevokeBySerial(t *testing.T) {
86 map[string]any{"serial": serial, "reason": "lost yubikey"}) 86 map[string]any{"serial": serial, "reason": "lost yubikey"})
87 require.Equal(t, http.StatusNoContent, resp.StatusCode) 87 require.Equal(t, http.StatusNoContent, resp.StatusCode)
88 88
89 revoked, err := st.IsSSHCertRevoked(serial) 89 revoked, err := st.IsSSHCertRevoked(testTenant, serial)
90 require.NoError(t, err) 90 require.NoError(t, err)
91 assert.True(t, revoked) 91 assert.True(t, revoked)
92 92
@@ -123,7 +123,7 @@ func TestSSHCertRevokeByCertLine(t *testing.T) {
123 map[string]any{"certificate": line}) 123 map[string]any{"certificate": line})
124 require.Equal(t, http.StatusNoContent, resp.StatusCode) 124 require.Equal(t, http.StatusNoContent, resp.StatusCode)
125 125
126 revoked, err := st.IsSSHCertRevoked(cert.Serial) 126 revoked, err := st.IsSSHCertRevoked(testTenant, cert.Serial)
127 require.NoError(t, err) 127 require.NoError(t, err)
128 assert.True(t, revoked, "the pasted cert's serial must be revoked") 128 assert.True(t, revoked, "the pasted cert's serial must be revoked")
129 } 129 }
internal/server/boot/boot.go
Old New
@@ -15,7 +15,9 @@ import (
15 "fmt" 15 "fmt"
16 "log/slog" 16 "log/slog"
17 "net/http" 17 "net/http"
18 "os"
18 "os/signal" 19 "os/signal"
20 "strings"
19 "syscall" 21 "syscall"
20 "time" 22 "time"
21 23
@@ -96,6 +98,11 @@ func run(cfgPath string) error {
96 if err != nil { 98 if err != nil {
97 return fmt.Errorf("config %s: %w", cfgPath, err) 99 return fmt.Errorf("config %s: %w", cfgPath, err)
98 } 100 }
101 // Install the log handler before anything else logs, so the first line the
102 // server writes already obeys the configured floor.
103 if err := installLogger(cfg.LogLevel); err != nil {
104 return fmt.Errorf("config: %w", err)
105 }
99 106
100 // The key that seals the on-disk host CA and gate host key — the only key 107 // The key that seals the on-disk host CA and gate host key — the only key
101 // material this server encrypts at rest — decoded once and handed to each 108 // material this server encrypts at rest — decoded once and handed to each
@@ -221,6 +228,13 @@ func run(cfgPath string) error {
221 // Publish the host CA: no-op when the gate is off, so the endpoint 404s. 228 // Publish the host CA: no-op when the gate is off, so the endpoint 404s.
222 sshGate.wireAPI(a) 229 sshGate.wireAPI(a)
223 230
231 // Say the policy out loud at boot: a max age is a deadline, not a rotation —
232 // nothing re-issues a credential, so every host reaching it needs enrolling
233 // again by hand. An operator who set it should see that stated somewhere.
234 if maxCredAge > 0 {
235 slog.Warn("host credentials expire by age; nothing renews them, so each host must be re-enrolled before it elapses",
236 "credential_max_age", maxCredAge.String())
237 }
224 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) 238 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge)
225 // Certify the host keys guests generate for themselves. No-op when the gate 239 // Certify the host keys guests generate for themselves. No-op when the gate
226 // is off, and then no guest waits for a certificate. 240 // is off, and then no guest waits for a certificate.
@@ -328,7 +342,7 @@ func run(cfgPath string) error {
328 // from the live server without bouncing the process. 342 // from the live server without bouncing the process.
329 covsnap.Install(ctx) 343 covsnap.Install(ctx)
330 344
331 srv := httpServer(cfg.HTTPListen, root) 345 srv := httpServer(cfg.HTTPListen, securityHeaders(root))
332 go func() { 346 go func() {
333 slog.Info("http listening", "addr", cfg.HTTPListen) 347 slog.Info("http listening", "addr", cfg.HTTPListen)
334 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 348 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -398,3 +412,34 @@ func defaultImages(in map[string]serverconfig.DefaultImage) map[string]api.Defau
398 } 412 }
399 return out 413 return out
400 } 414 }
415
416 // installLogger sets the process-wide slog handler at the configured floor.
417 // Without this the default handler applies, whose floor is info — which is why
418 // every Debug line in the server was unreachable in a running plane no matter
419 // what the operator did.
420 //
421 // An empty level means info, which is the floor the default handler already
422 // used — so a config that says nothing keeps the same lines. It does not keep
423 // the same FORMATTING: this installs a TextHandler where the log package's own
424 // handler was, so an unset level still changes how a line looks, and anything
425 // parsing the journal should be checked once.
426 //
427 // An unrecognized level is a config error, not a silent fallback: an operator
428 // who wrote "verbose" and got info would conclude the logging is broken rather
429 // than the spelling.
430 func installLogger(level string) error {
431 lv := slog.LevelInfo
432 switch strings.ToLower(strings.TrimSpace(level)) {
433 case "", "info":
434 case "debug":
435 lv = slog.LevelDebug
436 case "warn", "warning":
437 lv = slog.LevelWarn
438 case "error":
439 lv = slog.LevelError
440 default:
441 return fmt.Errorf("log_level %q is not one of debug, info, warn, error", level)
442 }
443 slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lv})))
444 return nil
445 }
internal/server/boot/boot_test.go
Old New
@@ -0,0 +1,32 @@
1 package boot
2
3 import (
4 "bytes"
5 "log/slog"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // TestInstallLoggerLevels pins the floors an operator can ask for, and that a
13 // typo is refused rather than silently becoming info.
14 func TestInstallLoggerLevels(t *testing.T) {
15 for _, lv := range []string{"", "info", "debug", "warn", "warning", "error", "DEBUG", " info "} {
16 require.NoError(t, installLogger(lv), "level %q", lv)
17 }
18 err := installLogger("verbose")
19 require.Error(t, err)
20 assert.Contains(t, err.Error(), "debug, info, warn, error")
21 }
22
23 // TestInstallLoggerDebugReachable is the point of the change: with the floor at
24 // debug, a Debug line actually emits. Under the default handler it never did.
25 func TestInstallLoggerDebugReachable(t *testing.T) {
26 var buf bytes.Buffer
27 prev := slog.Default()
28 t.Cleanup(func() { slog.SetDefault(prev) })
29 slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
30 slog.Debug("sshgate tunnel closed", "vm", "vm-1")
31 assert.Contains(t, buf.String(), "sshgate tunnel closed")
32 }
internal/server/boot/headers.go
Old New
@@ -0,0 +1,80 @@
1 package boot
2
3 import (
4 "net/http"
5 "strings"
6
7 "github.com/a73x/eitri/internal/server/web"
8 )
9
10 // hstsMaxAge is two years in seconds, the value the preload lists expect.
11 //
12 // includeSubDomains is deliberately absent. This plane answers on one hostname,
13 // and the directive would bind every sibling under the registered domain —
14 // including hosts this server knows nothing about and does not serve. That is a
15 // commitment for an operator to make on purpose, not a side effect of turning
16 // on a header.
17 const hstsMaxAge = "max-age=63072000"
18
19 // securityHeaders sets the response headers the browser-facing surface needs.
20 // It wraps the ROOT mux, so the SPA, the API and the sign-in redirects are all
21 // covered — a header set on only some responses protects only some of them.
22 //
23 // Go's http.Error already sends nosniff on error replies, which is why the
24 // absence of these was easy to miss: the 4xx a probe sees looks defended while
25 // every 200 goes out bare.
26 func securityHeaders(next http.Handler) http.Handler {
27 csp := contentSecurityPolicy()
28 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 h := w.Header()
30 h.Set("Content-Security-Policy", csp)
31 h.Set("X-Content-Type-Options", "nosniff")
32 // frame-ancestors in the CSP is the modern control and covers this; the
33 // legacy header stays for clients that honour only the old one.
34 h.Set("X-Frame-Options", "DENY")
35 h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
36 // HSTS is meaningful only once the browser is already on TLS, and it is
37 // what closes the window the plain-HTTP redirect leaves open: without it
38 // the FIRST request of a session still goes out in the clear. TLS is
39 // terminated by the proxy in front, so trust its forwarded scheme and
40 // fall back to whether this hop was itself TLS.
41 if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
42 h.Set("Strict-Transport-Security", hstsMaxAge)
43 }
44 next.ServeHTTP(w, r)
45 })
46 }
47
48 // contentSecurityPolicy builds the policy served with every response.
49 //
50 // script-src carries the hash of the SPA's inline entry point rather than
51 // 'unsafe-inline' — with a hash present a browser IGNORES 'unsafe-inline', so
52 // injected script is refused while the console still boots. The hashes are
53 // computed from the embedded index.html at startup, so a rebuild that changes
54 // that script needs no change here.
55 //
56 // style-src keeps 'unsafe-inline' because the SPA sets style attributes on
57 // elements, which no hash can cover. An inline style cannot execute script, so
58 // this is the cheap half of the policy to concede.
59 //
60 // connect-src stays 'self': the console talks to its own origin for the API,
61 // the SSE stream and the console WebSocket (ws: over the same host is covered
62 // by 'self' in modern browsers).
63 func contentSecurityPolicy() string {
64 script := "'self'"
65 for _, h := range web.InlineScriptHashes() {
66 script += " '" + h + "'"
67 }
68 return strings.Join([]string{
69 "default-src 'self'",
70 "script-src " + script,
71 "style-src 'self' 'unsafe-inline'",
72 "img-src 'self' data:",
73 "font-src 'self'",
74 "connect-src 'self'",
75 "frame-ancestors 'none'",
76 "base-uri 'self'",
77 "form-action 'self'",
78 "object-src 'none'",
79 }, "; ")
80 }
internal/server/boot/headers_test.go
Old New
@@ -0,0 +1,66 @@
1 package boot
2
3 import (
4 "crypto/tls"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 func serveWrapped(t *testing.T, r *http.Request) *http.Response {
15 t.Helper()
16 rec := httptest.NewRecorder()
17 securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18 w.WriteHeader(http.StatusOK)
19 })).ServeHTTP(rec, r)
20 return rec.Result()
21 }
22
23 // TestSecurityHeadersOnSuccess is the finding this closes: a 200 used to carry
24 // none of these. Error replies looked defended only because http.Error adds
25 // nosniff of its own.
26 func TestSecurityHeadersOnSuccess(t *testing.T) {
27 resp := serveWrapped(t, httptest.NewRequest("GET", "/", nil))
28 h := resp.Header
29 assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
30 assert.Equal(t, "DENY", h.Get("X-Frame-Options"))
31 assert.Equal(t, "strict-origin-when-cross-origin", h.Get("Referrer-Policy"))
32 assert.Contains(t, h.Get("Content-Security-Policy"), "frame-ancestors 'none'")
33 assert.Contains(t, h.Get("Content-Security-Policy"), "object-src 'none'")
34 }
35
36 // TestHSTSOnlyOverTLS pins that the header is sent when the browser is on TLS —
37 // directly, or via the proxy that terminates it — and withheld otherwise, where
38 // it would mean nothing.
39 func TestHSTSOnlyOverTLS(t *testing.T) {
40 plain := httptest.NewRequest("GET", "/", nil)
41 assert.Empty(t, serveWrapped(t, plain).Header.Get("Strict-Transport-Security"))
42
43 proxied := httptest.NewRequest("GET", "/", nil)
44 proxied.Header.Set("X-Forwarded-Proto", "https")
45 assert.Equal(t, hstsMaxAge, serveWrapped(t, proxied).Header.Get("Strict-Transport-Security"))
46
47 direct := httptest.NewRequest("GET", "/", nil)
48 direct.TLS = &tls.ConnectionState{}
49 assert.Equal(t, hstsMaxAge, serveWrapped(t, direct).Header.Get("Strict-Transport-Security"))
50 }
51
52 // TestCSPScriptSrcIsHashedNotUnsafeInline is what makes the policy worth having:
53 // 'unsafe-inline' in script-src would let injected script run, and a browser
54 // ignores it entirely once a hash is present.
55 func TestCSPScriptSrcIsHashedNotUnsafeInline(t *testing.T) {
56 csp := contentSecurityPolicy()
57 var scriptSrc string
58 for _, d := range strings.Split(csp, "; ") {
59 if strings.HasPrefix(d, "script-src ") {
60 scriptSrc = d
61 }
62 }
63 require.NotEmpty(t, scriptSrc)
64 assert.NotContains(t, scriptSrc, "unsafe-inline")
65 assert.NotContains(t, scriptSrc, "unsafe-eval")
66 }
internal/server/boot/sshgate.go
Old New
@@ -1,10 +1,12 @@
1 package boot 1 package boot
2 2
3 import ( 3 import (
4 "encoding/json"
4 "errors" 5 "errors"
5 "fmt" 6 "fmt"
6 "log/slog" 7 "log/slog"
7 "net" 8 "net"
9 "sync/atomic"
8 "time" 10 "time"
9 11
10 "github.com/a73x/eitri/internal/server/api" 12 "github.com/a73x/eitri/internal/server/api"
@@ -191,7 +193,8 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service, fata
191 if err != nil { 193 if err != nil {
192 return fmt.Errorf("gate host cert signer: %w", err) 194 return fmt.Errorf("gate host cert signer: %w", err)
193 } 195 }
194 gate := sshgate.New(gateHostSigner, userCALookup(st), resolveVM(st), authorizeVM(st), svc.OpenTCP, revokedCert(st)) 196 gate := sshgate.New(gateHostSigner, userCALookup(st), resolveVM(st), authorizeVM(st), svc.OpenTCP,
197 revokedCert(st), gateAudit(st))
195 ln, err := net.Listen("tcp", g.listen) 198 ln, err := net.Listen("tcp", g.listen)
196 if err != nil { 199 if err != nil {
197 return fmt.Errorf("ssh gate listen: %w", err) 200 return fmt.Errorf("ssh gate listen: %w", err)
@@ -258,8 +261,8 @@ func vmLookup(st *store.Store) vmssh.VMLookup {
258 // revoked cert through) or fail-the-whole-gate (which a global close would 261 // revoked cert through) or fail-the-whole-gate (which a global close would
259 // amount to, DoSing every login on any transient error). 262 // amount to, DoSing every login on any transient error).
260 func revokedCert(st *store.Store) sshgate.Revoker { 263 func revokedCert(st *store.Store) sshgate.Revoker {
261 return func(serial uint64) bool { 264 return func(tenant string, serial uint64) bool {
262 revoked, err := st.IsSSHCertRevoked(serial) 265 revoked, err := st.IsSSHCertRevoked(tenant, serial)
263 if err != nil { 266 if err != nil {
264 slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err) 267 slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err)
265 return true 268 return true
@@ -282,3 +285,57 @@ func userCALookup(st *store.Store) sshgate.UserCALookup {
282 return tenant, ok 285 return tenant, ok
283 } 286 }
284 } 287 }
288
289 // gateAudit files gate events in the same append-only log the API writes to, in
290 // the same (tenant, action, detail-JSON) shape — so a tenant's trail reads as
291 // one story rather than two, and `GET /api/v1/audit` shows who reached which VM
292 // without a second place to look.
293 //
294 // An event the gate could not attribute (a refused login carries no tenant) is
295 // filed under the system scope, exactly as a denied enrollment is.
296 //
297 // Writes happen on ONE background goroutine behind a bounded queue, because the
298 // gate calls this on a live connection's goroutine and the store serializes
299 // every write onto a single connection shared with syncsvc and the API. Writing
300 // inline would put a database round trip between a client and its tunnel, and
301 // would stall logins under exactly the load that makes an audit trail worth
302 // having.
303 //
304 // A full queue DROPS, and says so. The alternative is blocking the gate, and an
305 // audit trail that can wedge the thing it audits is worse than one with a
306 // counted gap in it.
307 func gateAudit(st *store.Store) sshgate.Audit {
308 type event struct {
309 tenant, action string
310 detail map[string]string
311 }
312 q := make(chan event, gateAuditQueue)
313 go func() {
314 for e := range q {
315 raw, _ := json.Marshal(e.detail)
316 if err := st.AppendAudit(e.tenant, e.action, string(raw)); err != nil {
317 slog.Warn("gate audit append failed", "action", e.action, "err", err)
318 }
319 }
320 }()
321 var dropped atomic.Int64
322 return func(tenant, action string, detail map[string]string) {
323 if tenant == "" {
324 tenant = store.SystemTenant
325 }
326 select {
327 case q <- event{tenant, action, detail}:
328 default:
329 // Count rather than log per drop: the situation that fills this queue
330 // is the one where another log line per event is also the problem.
331 if n := dropped.Add(1); n == 1 || n%gateAuditQueue == 0 {
332 slog.Warn("gate audit queue full; events dropped", "dropped_total", n)
333 }
334 }
335 }
336 }
337
338 // gateAuditQueue bounds the audit backlog the gate may build up. Deep enough to
339 // absorb a burst of real logins, shallow enough that a flood is dropped rather
340 // than buffered into memory.
341 const gateAuditQueue = 256
internal/server/boot/sshgate_test.go
Old New
@@ -174,15 +174,15 @@ func TestRevokedCertFailsClosed(t *testing.T) {
174 tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com") 174 tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com")
175 175
176 revoked := revokedCert(s) 176 revoked := revokedCert(s)
177 assert.False(t, revoked(42), "an unknown serial is not revoked") 177 assert.False(t, revoked(tenant, 42), "an unknown serial is not revoked")
178 178
179 require.NoError(t, s.RevokeSSHCert(tenant, 42, "leaked laptop")) 179 require.NoError(t, s.RevokeSSHCert(tenant, 42, "leaked laptop"))
180 assert.True(t, revoked(42), "a revoked serial reads revoked") 180 assert.True(t, revoked(tenant, 42), "a revoked serial reads revoked")
181 181
182 // Force a store error: after Close the DB handle is dead and the lookup 182 // Force a store error: after Close the DB handle is dead and the lookup
183 // errors. The gate must fail closed — reject the connection. 183 // errors. The gate must fail closed — reject the connection.
184 require.NoError(t, s.Close()) 184 require.NoError(t, s.Close())
185 assert.True(t, revoked(43), "a store error must fail closed (reject)") 185 assert.True(t, revoked(tenant, 43), "a store error must fail closed (reject)")
186 } 186 }
187 187
188 // TestUserCALookupFailsClosed pins tenant attribution: a cert signed by a 188 // TestUserCALookupFailsClosed pins tenant attribution: a cert signed by a
internal/server/config/config.go
Old New
@@ -5,6 +5,15 @@ package config
5 // Config is the eitri-server config file schema (decoded from JSON). 5 // Config is the eitri-server config file schema (decoded from JSON).
6 type Config struct { 6 type Config struct {
7 HTTPListen string `json:"http_listen"` 7 HTTPListen string `json:"http_listen"`
8 // LogLevel sets the floor for structured logs: "debug", "info", "warn" or
9 // "error". Empty means "info", which is what Go's default handler does — so
10 // an unset value changes nothing.
11 //
12 // Without it the Debug lines throughout the server are dead code in a running
13 // plane: nothing configures a handler, so the default floor of info silently
14 // discards them and an operator diagnosing a live problem has no way to turn
15 // detail on short of a rebuild.
16 LogLevel string `json:"log_level"`
8 QUICListen string `json:"quic_listen"` 17 QUICListen string `json:"quic_listen"`
9 DBPath string `json:"db_path"` 18 DBPath string `json:"db_path"`
10 // AdminToken is retired: sign-in is OIDC (see OIDC below) and console 19 // AdminToken is retired: sign-in is OIDC (see OIDC below) and console
@@ -47,6 +56,17 @@ type Config struct {
47 // e.g. "2160h" for 90 days). Empty/zero disables — revocation via 56 // e.g. "2160h" for 90 days). Empty/zero disables — revocation via
48 // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; 57 // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism;
49 // max-age forces periodic re-enrollment and is opt-in defense-in-depth. 58 // max-age forces periodic re-enrollment and is opt-in defense-in-depth.
59 //
60 // READ THIS BEFORE SETTING IT. Nothing renews a host credential: one is
61 // minted once, at enrollment, and never re-issued. So this is not a rotation
62 // policy — it is a deadline. Every host whose credential reaches this age
63 // stops syncing and stays dark until an operator re-enrolls it BY HAND, and
64 // they will all reach it at whatever spread their enrollments had.
65 //
66 // Leaving it unset is therefore the safe default and not an oversight; the
67 // hole it leaves is that a leaked credential is valid until someone notices
68 // and revokes that host's generation. Closing that properly means renewal on
69 // the sync channel, which does not exist yet.
50 CredentialMaxAge string `json:"credential_max_age"` 70 CredentialMaxAge string `json:"credential_max_age"`
51 // AuditRetention bounds the audit_log age (Go duration; default "2160h" = 71 // AuditRetention bounds the audit_log age (Go duration; default "2160h" =
52 // 90 days; "0" disables pruning). Pruned at startup and daily. 72 // 90 days; "0" disables pruning). Pruned at startup and daily.
internal/server/sshgate/gate.go
Old New
@@ -15,6 +15,7 @@ import (
15 "io" 15 "io"
16 "log/slog" 16 "log/slog"
17 "net" 17 "net"
18 "strconv"
18 "strings" 19 "strings"
19 "time" 20 "time"
20 21
@@ -59,6 +60,10 @@ type Gate struct {
59 resolve Resolver 60 resolve Resolver
60 authorize Authorizer 61 authorize Authorizer
61 dial Dialer 62 dial Dialer
63 audit Audit
64 // starting bounds how many connections may sit in the UNAUTHENTICATED
65 // handshake at once — sshd's MaxStartups. See maxStartups.
66 starting chan struct{}
62 } 67 }
63 68
64 // Revoker reports whether the user cert bearing serial has been revoked. It is 69 // Revoker reports whether the user cert bearing serial has been revoked. It is
@@ -67,18 +72,47 @@ type Gate struct {
67 // on a store error so a DB hiccup rejects the single connection rather than 72 // on a store error so a DB hiccup rejects the single connection rather than
68 // silently letting a possibly-revoked cert through. 73 // silently letting a possibly-revoked cert through.
69 // 74 //
75 // It is asked about a serial WITHIN a tenant: a certificate is revoked by the
76 // tenant whose CA signed it, and only for that tenant. Answering fleet-wide
77 // would let any tenant deny a serial it has no claim to, and nothing can
78 // attribute a bare serial back to its owner — CAs are BYO, so eitri never sees
79 // the certificates they mint.
80 //
70 // SCOPE: revocation is enforced at the GATE ONLY. VM guests trust the CA 81 // SCOPE: revocation is enforced at the GATE ONLY. VM guests trust the CA
71 // (TrustedUserCAKeys) with NO guest-side KRL, so a revoked cert would still be 82 // (TrustedUserCAKeys) with NO guest-side KRL, so a revoked cert would still be
72 // accepted by a VM's sshd if a client reached it directly. That is fine for 83 // accepted by a VM's sshd if a client reached it directly. That is fine for
73 // single-user — VMs are reachable ONLY via this gate. 84 // single-user — VMs are reachable ONLY via this gate.
74 // Guest-side KRL distribution is a multi-user/rotation follow-up (the deferred 85 // Guest-side KRL distribution is a multi-user/rotation follow-up (the deferred
75 // CA-rotation-push problem). 86 // CA-rotation-push problem).
76 type Revoker func(serial uint64) bool 87 type Revoker func(tenant string, serial uint64) bool
77 88
78 // UserCALookup resolves a cert signature key to the tenant that registered it. 89 // UserCALookup resolves a cert signature key to the tenant that registered it.
79 // ok=false ⇒ the CA is not a registered tenant user CA ⇒ reject the cert. 90 // ok=false ⇒ the CA is not a registered tenant user CA ⇒ reject the cert.
80 type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool) 91 type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool)
81 92
93 // Audit records one gate event. It takes the same (tenant, action, detail)
94 // shape the API's audit trail already uses, so gate rows read like every other
95 // row rather than like a second scheme.
96 //
97 // A tenant of "" means the event has no authenticated tenant yet — a refused
98 // login — and the caller files it under whatever scope it uses for unattributed
99 // events. The gate is a leaf and does not know that name.
100 //
101 // It MUST NOT block: it is called on the connection's own goroutine, between a
102 // client and its tunnel, and the store behind the real implementation serializes
103 // every write onto one connection shared with the rest of the plane. An
104 // implementation that writes synchronously will stall logins under exactly the
105 // load that makes the trail interesting. A nil Audit disables gate auditing.
106 type Audit func(tenant, action string, detail map[string]string)
107
108 // Extension keys carrying the authenticated cert's identity from the auth
109 // callback to the handlers that audit it. Per-connection by construction, for
110 // the same reason tenantExt is.
111 const (
112 serialExt = "cert_serial"
113 keyIDExt = "cert_key_id"
114 )
115
82 // New builds a Gate that presents hostKey, trusts every certificate signed by a 116 // New builds a Gate that presents hostKey, trusts every certificate signed by a
83 // CA that userCAs resolves to a tenant, resolves VM names with resolve, gates 117 // CA that userCAs resolves to a tenant, resolves VM names with resolve, gates
84 // them with authorize, rejects certs isRevoked flags, and tunnels through dial. 118 // them with authorize, rejects certs isRevoked flags, and tunnels through dial.
@@ -88,14 +122,27 @@ type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool)
88 // (userCAs), making the downstream cert.tenant == vm.tenant authz a real 122 // (userCAs), making the downstream cert.tenant == vm.tenant authz a real
89 // cryptographic boundary — a cert can only ever carry the tenant of the CA that 123 // cryptographic boundary — a cert can only ever carry the tenant of the CA that
90 // signed it. 124 // signed it.
91 func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { 125 func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker, audit Audit) *Gate {
92 checker := &ssh.CertChecker{ 126 checker := &ssh.CertChecker{
93 IsUserAuthority: func(auth ssh.PublicKey) bool { _, ok := userCAs(auth); return ok }, 127 IsUserAuthority: func(auth ssh.PublicKey) bool { _, ok := userCAs(auth); return ok },
94 } 128 }
95 // CheckCert consults IsRevoked during validation: a true result fails the 129 // CheckCert consults IsRevoked during validation: a true result fails the
96 // cert authentication outright, so a revoked cert cannot open the tunnel. 130 // cert authentication outright, so a revoked cert cannot open the tunnel.
131 //
132 // The tenant asked about is the one that registered the signing CA — the same
133 // derivation the connection's identity uses, so a certificate is only ever
134 // measured against its own tenant's revocations. A signing key that resolves
135 // to no tenant is treated as revoked: the auth below refuses it anyway, and
136 // answering "not revoked" for a CA we do not know would be the wrong default
137 // to leave lying around.
97 if isRevoked != nil { 138 if isRevoked != nil {
98 checker.IsRevoked = func(cert *ssh.Certificate) bool { return isRevoked(cert.Serial) } 139 checker.IsRevoked = func(cert *ssh.Certificate) bool {
140 tenant, ok := userCAs(cert.SignatureKey)
141 if !ok {
142 return true
143 }
144 return isRevoked(tenant, cert.Serial)
145 }
99 } 146 }
100 cfg := &ssh.ServerConfig{ 147 cfg := &ssh.ServerConfig{
101 PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 148 PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
@@ -139,12 +186,21 @@ func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize A
139 // the target VM's tenant, so the cert's signing CA cryptographically 186 // the target VM's tenant, so the cert's signing CA cryptographically
140 // bounds which tenant's VMs the connection may reach. 187 // bounds which tenant's VMs the connection may reach.
141 tenantExt: tenant, 188 tenantExt: tenant,
189 // Carried so the connection can be audited by the certificate
190 // that opened it: the serial is what a revocation names, which
191 // makes a gate row answer "was this the cert we later revoked?".
192 serialExt: strconv.FormatUint(cert.Serial, 10),
193 keyIDExt: cert.KeyId,
142 }, 194 },
143 }, nil 195 }, nil
144 }, 196 },
145 } 197 }
146 cfg.AddHostKey(hostKey) 198 cfg.AddHostKey(hostKey)
147 return &Gate{cfg: cfg, resolve: resolve, authorize: authorize, dial: dial} 199 if audit == nil {
200 audit = func(string, string, map[string]string) {}
201 }
202 return &Gate{cfg: cfg, resolve: resolve, authorize: authorize, dial: dial, audit: audit,
203 starting: make(chan struct{}, maxStartups)}
148 } 204 }
149 205
150 // Serve accepts connections on l until it returns an error (e.g. l is closed). 206 // Serve accepts connections on l until it returns an error (e.g. l is closed).
@@ -165,17 +221,63 @@ func (g *Gate) Serve(l net.Listener) error {
165 // so tests can shrink it; not part of the public API. 221 // so tests can shrink it; not part of the public API.
166 var handshakeGrace = 30 * time.Second 222 var handshakeGrace = 30 * time.Second
167 223
224 // maxStartups bounds concurrent unauthenticated handshakes, mirroring sshd's
225 // MaxStartups. handshakeGrace already bounds how LONG one stalled client may
226 // hold a slot; this bounds HOW MANY, which is the half that stops a flood of
227 // merely-slow clients from crowding out every real admin login. A var (not
228 // const) so tests can shrink it; not part of the public API.
229 //
230 // The slot is released the moment the handshake finishes, NOT when the tunnel
231 // closes: the resource being rationed is the pre-auth window, and an
232 // authenticated session may legitimately last hours. Holding slots for the life
233 // of a tunnel would make a busy gate refuse logins it has every reason to take.
234 var maxStartups = 64
235
168 // handleConn runs the SSH handshake and dispatches channels for one connection. 236 // handleConn runs the SSH handshake and dispatches channels for one connection.
169 func (g *Gate) handleConn(nConn net.Conn) { 237 func (g *Gate) handleConn(nConn net.Conn) {
170 defer nConn.Close() 238 defer nConn.Close()
239 remote := remoteHost(nConn)
240 // Take a startup slot, or shed the connection now. Refusing immediately is
241 // the honest answer under load: the alternative is queueing behind a full
242 // grace window, which delays every real login instead of one attacker's.
243 select {
244 case g.starting <- struct{}{}:
245 default:
246 // Logged, deliberately NOT audited. This is the cheapest event on the
247 // gate to provoke — it costs an attacker one TCP connection — and an
248 // audit row costs a serialized database write and ninety days of
249 // retention. Auditing here would turn the defence against a flood into
250 // the flood's amplifier. sshd sends refusals to syslog for this reason.
251 // The line above the auth boundary is the rule: what got far enough to
252 // attempt authentication is audited; what was refused before that is
253 // logged.
254 slog.Warn("sshgate shed connection: too many starting", "remote", remote)
255 return
256 }
257 released := false
258 release := func() {
259 if !released {
260 released = true
261 <-g.starting
262 }
263 }
264 defer release()
265
171 // Deadline covers only the pre-auth handshake; cleared once it completes so 266 // Deadline covers only the pre-auth handshake; cleared once it completes so
172 // it never applies to the long-lived tunnel that follows. 267 // it never applies to the long-lived tunnel that follows.
173 _ = nConn.SetDeadline(time.Now().Add(handshakeGrace)) 268 _ = nConn.SetDeadline(time.Now().Add(handshakeGrace))
174 sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg) 269 sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg)
175 if err != nil { 270 if err != nil {
271 // A refused login has no tenant to file under — the caller decides where
272 // unattributed events go. The error is the reason as x/crypto phrased it;
273 // it names no key material.
274 g.audit("", "gate.auth.denied", map[string]string{"remote": remote, "reason": err.Error()})
176 return // handshake, auth failure, or grace timeout — nothing to serve 275 return // handshake, auth failure, or grace timeout — nothing to serve
177 } 276 }
178 _ = nConn.SetDeadline(time.Time{}) 277 _ = nConn.SetDeadline(time.Time{})
278 // Authenticated: the pre-auth window is over, so give the slot back before
279 // serving a tunnel that may outlast every other connection on the gate.
280 release()
179 defer sConn.Close() 281 defer sConn.Close()
180 282
181 // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn 283 // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn
@@ -183,10 +285,15 @@ func (g *Gate) handleConn(nConn net.Conn) {
183 // legitimate here. Draining the channel also keeps the transport unblocked. 285 // legitimate here. Draining the channel also keeps the transport unblocked.
184 go rejectRequests(reqs) 286 go rejectRequests(reqs)
185 287
186 tenant := "" 288 tenant, serial, keyID := "", "", ""
187 if sConn.Permissions != nil { 289 if sConn.Permissions != nil {
188 tenant = sConn.Permissions.Extensions[tenantExt] 290 tenant = sConn.Permissions.Extensions[tenantExt]
291 serial = sConn.Permissions.Extensions[serialExt]
292 keyID = sConn.Permissions.Extensions[keyIDExt]
189 } 293 }
294 g.audit(tenant, "gate.auth", map[string]string{
295 "remote": remote, "serial": serial, "key_id": keyID, "user": sConn.User(),
296 })
190 for newChan := range chans { 297 for newChan := range chans {
191 // Only direct-tcpip is permitted; this rejects session/exec/shell/ 298 // Only direct-tcpip is permitted; this rejects session/exec/shell/
192 // subsystem/x11/auth-agent — any granted session is code-exec on the server. 299 // subsystem/x11/auth-agent — any granted session is code-exec on the server.
@@ -194,7 +301,7 @@ func (g *Gate) handleConn(nConn net.Conn) {
194 _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") 301 _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted")
195 continue 302 continue
196 } 303 }
197 go g.handleDirectTCPIP(newChan, tenant) 304 go g.handleDirectTCPIP(newChan, tenant, remote)
198 } 305 }
199 } 306 }
200 307
@@ -210,15 +317,24 @@ func rejectRequests(reqs <-chan *ssh.Request) {
210 317
211 // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, 318 // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM,
212 // and bridges the channel to the VM byte-for-byte. 319 // and bridges the channel to the VM byte-for-byte.
213 func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { 320 func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant, remote string) {
321 // deny records why a tunnel was refused. The requested name is
322 // caller-controlled, so it is bounded before it reaches the trail.
323 deny := func(target, reason string) {
324 g.audit(tenant, "gate.tunnel.denied", map[string]string{
325 "remote": remote, "target": truncate(target, 64), "reason": reason,
326 })
327 }
214 var p directTCPIP 328 var p directTCPIP
215 if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { 329 if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil {
330 deny("", "malformed request")
216 _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") 331 _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request")
217 return 332 return
218 } 333 }
219 // Port policy: only 22, and reject others rather than silently rewriting, so 334 // Port policy: only 22, and reject others rather than silently rewriting, so
220 // intent stays auditable. 335 // intent stays auditable.
221 if p.PortToConnect != 22 { 336 if p.PortToConnect != 22 {
337 deny(p.HostToConnect, "port "+strconv.FormatUint(uint64(p.PortToConnect), 10)+" not permitted")
222 _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") 338 _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted")
223 return 339 return
224 } 340 }
@@ -235,26 +351,31 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) {
235 bare := p.HostToConnect 351 bare := p.HostToConnect
236 if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found { 352 if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found {
237 if prefix != tenant || rest == "" { 353 if prefix != tenant || rest == "" {
354 deny(p.HostToConnect, "unknown VM")
238 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") 355 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
239 return 356 return
240 } 357 }
241 bare = rest 358 bare = rest
242 } 359 }
243 if bare == "" { 360 if bare == "" {
361 deny(p.HostToConnect, "unknown VM")
244 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") 362 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
245 return 363 return
246 } 364 }
247 hostID, vmID, ok := g.resolve(tenant, bare) 365 hostID, vmID, ok := g.resolve(tenant, bare)
248 if !ok { 366 if !ok {
367 deny(p.HostToConnect, "unknown VM")
249 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") 368 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
250 return 369 return
251 } 370 }
252 if !g.authorize(tenant, vmID) { 371 if !g.authorize(tenant, vmID) {
372 deny(p.HostToConnect, "not authorized for this VM")
253 _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") 373 _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM")
254 return 374 return
255 } 375 }
256 rwc, err := g.dial(context.Background(), hostID, vmID, 22) 376 rwc, err := g.dial(context.Background(), hostID, vmID, 22)
257 if err != nil { 377 if err != nil {
378 deny(p.HostToConnect, "host unreachable")
258 _ = newChan.Reject(ssh.ConnectionFailed, "cannot reach VM") 379 _ = newChan.Reject(ssh.ConnectionFailed, "cannot reach VM")
259 return 380 return
260 } 381 }
@@ -264,6 +385,9 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) {
264 return 385 return
265 } 386 }
266 go ssh.DiscardRequests(chReqs) // no channel requests (env/pty/exec) are honored 387 go ssh.DiscardRequests(chReqs) // no channel requests (env/pty/exec) are honored
388 g.audit(tenant, "gate.tunnel", map[string]string{
389 "remote": remote, "vm_id": vmID, "host_id": hostID, "target": truncate(bare, 64),
390 })
267 391
268 // Two pumps, raw bytes (mirrors console.go): close both legs on either EOF. 392 // Two pumps, raw bytes (mirrors console.go): close both legs on either EOF.
269 done := make(chan struct{}, 2) 393 done := make(chan struct{}, 2)
@@ -273,3 +397,21 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) {
273 <-done 397 <-done
274 slog.Debug("sshgate tunnel closed", "vm", vmID) 398 slog.Debug("sshgate tunnel closed", "vm", vmID)
275 } 399 }
400
401 // remoteHost is the client's address without its port — the field an operator
402 // scans an audit trail by. An address that will not split is used whole.
403 func remoteHost(c net.Conn) string {
404 host, _, err := net.SplitHostPort(c.RemoteAddr().String())
405 if err != nil {
406 return c.RemoteAddr().String()
407 }
408 return host
409 }
410
411 // truncate bounds a caller-controlled string before it reaches the audit trail.
412 func truncate(s string, n int) string {
413 if len(s) > n {
414 return s[:n]
415 }
416 return s
417 }
internal/server/sshgate/gate_test.go
Old New
@@ -7,6 +7,7 @@ import (
7 "crypto/rand" 7 "crypto/rand"
8 "io" 8 "io"
9 "net" 9 "net"
10 "sync"
10 "testing" 11 "testing"
11 "time" 12 "time"
12 13
@@ -72,6 +73,7 @@ func mintCertSignerNoPrincipals(t *testing.T, ca, clientKey ssh.Signer) ssh.Sign
72 // testGate wires a gate over a loopback listener and returns the client-side 73 // testGate wires a gate over a loopback listener and returns the client-side
73 // dial address plus the wired fakes' observed state. 74 // dial address plus the wired fakes' observed state.
74 type testGate struct { 75 type testGate struct {
76 gate *Gate
75 addr string 77 addr string
76 hostKey ssh.Signer 78 hostKey ssh.Signer
77 dialCalls chan [3]string // hostID, vmID, port-as-string per dial 79 dialCalls chan [3]string // hostID, vmID, port-as-string per dial
@@ -100,8 +102,9 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate {
100 go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd 102 go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd
101 return a, nil 103 return a, nil
102 } 104 }
103 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil) 105 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil, nil)
104 106
107 tg.gate = g
105 l, err := net.Listen("tcp", "127.0.0.1:0") 108 l, err := net.Listen("tcp", "127.0.0.1:0")
106 require.NoError(t, err) 109 require.NoError(t, err)
107 tg.addr = l.Addr().String() 110 tg.addr = l.Addr().String()
@@ -110,6 +113,9 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate {
110 return tg 113 return tg
111 } 114 }
112 115
116 // gateStarting exposes the gate's startup semaphore for the MaxStartups test.
117 func (tg *testGate) gateStarting() chan struct{} { return tg.gate.starting }
118
113 // singleCALookup builds a UserCALookup that trusts exactly one CA public key, 119 // singleCALookup builds a UserCALookup that trusts exactly one CA public key,
114 // stamping its connections with tenant. Any other signing key resolves to 120 // stamping its connections with tenant. Any other signing key resolves to
115 // ok=false (rejected) — the test-side analogue of the DB-backed lookup. 121 // ok=false (rejected) — the test-side analogue of the DB-backed lookup.
@@ -140,7 +146,8 @@ func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *te
140 go func() { _, _ = io.Copy(b, b); b.Close() }() 146 go func() { _, _ = io.Copy(b, b); b.Close() }()
141 return a, nil 147 return a, nil
142 } 148 }
143 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, isRevoked) 149 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, isRevoked, nil)
150 tg.gate = g
144 l, err := net.Listen("tcp", "127.0.0.1:0") 151 l, err := net.Listen("tcp", "127.0.0.1:0")
145 require.NoError(t, err) 152 require.NoError(t, err)
146 tg.addr = l.Addr().String() 153 tg.addr = l.Addr().String()
@@ -202,7 +209,8 @@ func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey
202 go func() { _, _ = io.Copy(b, b); b.Close() }() 209 go func() { _, _ = io.Copy(b, b); b.Close() }()
203 return a, nil 210 return a, nil
204 } 211 }
205 g := New(hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil) 212 g := New(hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil, nil)
213 tg.gate = g
206 l, err := net.Listen("tcp", "127.0.0.1:0") 214 l, err := net.Listen("tcp", "127.0.0.1:0")
207 require.NoError(t, err) 215 require.NoError(t, err)
208 tg.addr = l.Addr().String() 216 tg.addr = l.Addr().String()
@@ -413,7 +421,8 @@ func TestGateBareNameForeignTenantRejected(t *testing.T) {
413 go func() { _, _ = io.Copy(b, b); b.Close() }() 421 go func() { _, _ = io.Copy(b, b); b.Close() }()
414 return a, nil 422 return a, nil
415 } 423 }
416 g := New(tg.hostKey, singleCALookup(ca.PublicKey(), "default"), resolve, authorize, dial, nil) 424 g := New(tg.hostKey, singleCALookup(ca.PublicKey(), "default"), resolve, authorize, dial, nil, nil)
425 tg.gate = g
417 l, err := net.Listen("tcp", "127.0.0.1:0") 426 l, err := net.Listen("tcp", "127.0.0.1:0")
418 require.NoError(t, err) 427 require.NoError(t, err)
419 tg.addr = l.Addr().String() 428 tg.addr = l.Addr().String()
@@ -499,7 +508,7 @@ func TestGateRejectsRevokedCert(t *testing.T) {
499 const serial = uint64(0xDEADBEEFCAFEF00D) 508 const serial = uint64(0xDEADBEEFCAFEF00D)
500 509
501 // Revoked ⇒ auth fails. 510 // Revoked ⇒ auth fails.
502 revoked := startGateRevoked(t, ca.PublicKey(), func(s uint64) bool { return s == serial }) 511 revoked := startGateRevoked(t, ca.PublicKey(), func(_ string, s uint64) bool { return s == serial })
503 cfg := &ssh.ClientConfig{ 512 cfg := &ssh.ClientConfig{
504 User: "ubuntu", 513 User: "ubuntu",
505 Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerSerial(t, ca, newSigner(t), serial))}, 514 Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerSerial(t, ca, newSigner(t), serial))},
@@ -510,7 +519,7 @@ func TestGateRejectsRevokedCert(t *testing.T) {
510 require.Error(t, err, "a revoked cert must fail auth at the gate") 519 require.Error(t, err, "a revoked cert must fail auth at the gate")
511 520
512 // Not revoked ⇒ the same serial authenticates and tunnels. 521 // Not revoked ⇒ the same serial authenticates and tunnels.
513 allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false }) 522 allowed := startGateRevoked(t, ca.PublicKey(), func(string, uint64) bool { return false })
514 client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) 523 client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial))
515 conn, err := client.Dial("tcp", "default.vm1:22") 524 conn, err := client.Dial("tcp", "default.vm1:22")
516 require.NoError(t, err, "a non-revoked cert must still tunnel") 525 require.NoError(t, err, "a non-revoked cert must still tunnel")
@@ -550,7 +559,7 @@ func TestGateTrustsRegisteredCARejectsUnknown(t *testing.T) {
550 resolve := func(string, string) (string, string, bool) { return "", "", false } 559 resolve := func(string, string) (string, string, bool) { return "", "", false }
551 authorize := func(string, string) bool { return true } 560 authorize := func(string, string) bool { return true }
552 dial := func(context.Context, string, string, uint32) (io.ReadWriteCloser, error) { return nil, nil } 561 dial := func(context.Context, string, string, uint32) (io.ReadWriteCloser, error) { return nil, nil }
553 g := New(newSigner(t), lookup, resolve, authorize, dial, nil) 562 g := New(newSigner(t), lookup, resolve, authorize, dial, nil, nil)
554 cb := g.cfg.PublicKeyCallback 563 cb := g.cfg.PublicKeyCallback
555 564
556 // A cert signed by the registered CA authenticates and is stamped "acme". 565 // A cert signed by the registered CA authenticates and is stamped "acme".
@@ -566,3 +575,214 @@ func TestGateTrustsRegisteredCARejectsUnknown(t *testing.T) {
566 _, err = cb(nil, evilCert) 575 _, err = cb(nil, evilCert)
567 require.Error(t, err, "a cert signed by an unregistered CA must be rejected") 576 require.Error(t, err, "a cert signed by an unregistered CA must be rejected")
568 } 577 }
578
579 // auditLog is a concurrency-safe recorder standing in for the store-backed
580 // audit sink. The gate calls it from each connection's own goroutine.
581 type auditLog struct {
582 mu sync.Mutex
583 events []auditEvent
584 }
585
586 type auditEvent struct {
587 tenant, action string
588 detail map[string]string
589 }
590
591 func (a *auditLog) record() Audit {
592 return func(tenant, action string, detail map[string]string) {
593 a.mu.Lock()
594 defer a.mu.Unlock()
595 a.events = append(a.events, auditEvent{tenant, action, detail})
596 }
597 }
598
599 // waitFor returns the first recorded event with the given action, polling until
600 // it appears — the gate audits on the connection goroutine, so a client call
601 // can return before the row lands.
602 func (a *auditLog) waitFor(t *testing.T, action string) auditEvent {
603 t.Helper()
604 for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
605 a.mu.Lock()
606 for _, e := range a.events {
607 if e.action == action {
608 a.mu.Unlock()
609 return e
610 }
611 }
612 a.mu.Unlock()
613 time.Sleep(5 * time.Millisecond)
614 }
615 a.mu.Lock()
616 defer a.mu.Unlock()
617 t.Fatalf("no %q event; recorded %v", action, a.events)
618 return auditEvent{}
619 }
620
621 // startGateAudited is startGate with an audit sink wired, so a test can assert
622 // the trail the gate leaves rather than only the bytes it moves.
623 func startGateAudited(t *testing.T, userCA ssh.PublicKey, authorized bool) (*testGate, *auditLog) {
624 t.Helper()
625 log := &auditLog{}
626 tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: authorized}
627 resolve := func(tenant, name string) (string, string, bool) {
628 if tenant == "default" && name == "vm1" {
629 return "host-1", "vm-1", true
630 }
631 return "", "", false
632 }
633 authorize := func(tenant, vmID string) bool { return tg.authorized }
634 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) {
635 tg.dialCalls <- [3]string{hostID, vmID, "22"}
636 a, b := net.Pipe()
637 go func() { _, _ = io.Copy(b, b); b.Close() }()
638 return a, nil
639 }
640 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil, log.record())
641 tg.gate = g
642 l, err := net.Listen("tcp", "127.0.0.1:0")
643 require.NoError(t, err)
644 tg.addr = l.Addr().String()
645 go func() { _ = g.Serve(l) }()
646 t.Cleanup(func() { _ = l.Close() })
647 return tg, log
648 }
649
650 // TestGateAuditsAuthAndTunnel is the gate's answer to "who reached which VM".
651 // Both halves must be recorded: the certificate that opened the connection, and
652 // the VM it was pointed at.
653 func TestGateAuditsAuthAndTunnel(t *testing.T) {
654 ca := newSigner(t)
655 tg, log := startGateAudited(t, ca.PublicKey(), true)
656 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
657
658 auth := log.waitFor(t, "gate.auth")
659 assert.Equal(t, "default", auth.tenant, "tenant comes from the signing CA")
660 // The serial is the field a revocation names, so it is what ties a session
661 // back to a certificate after the fact.
662 assert.Equal(t, "1", auth.detail["serial"])
663 assert.Equal(t, "ubuntu", auth.detail["key_id"])
664 assert.NotEmpty(t, auth.detail["remote"])
665
666 conn, err := client.Dial("tcp", "default.vm1:22")
667 require.NoError(t, err)
668 defer conn.Close()
669
670 tun := log.waitFor(t, "gate.tunnel")
671 assert.Equal(t, "default", tun.tenant)
672 assert.Equal(t, "vm-1", tun.detail["vm_id"])
673 assert.Equal(t, "host-1", tun.detail["host_id"])
674 }
675
676 // TestGateAuditsRefusedLogin pins that a login the gate turns away is recorded
677 // too — an unattributed event, since no tenant was ever established.
678 func TestGateAuditsRefusedLogin(t *testing.T) {
679 ca, foreign := newSigner(t), newSigner(t)
680 tg, log := startGateAudited(t, ca.PublicKey(), true)
681
682 cfg := &ssh.ClientConfig{
683 User: "probe",
684 Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSigner(t, foreign, newSigner(t)))},
685 HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()),
686 Timeout: 5 * time.Second,
687 }
688 c, err := ssh.Dial("tcp", tg.addr, cfg)
689 require.Error(t, err, "a cert from an unregistered CA must not authenticate")
690 if c != nil {
691 _ = c.Close()
692 }
693
694 ev := log.waitFor(t, "gate.auth.denied")
695 assert.Empty(t, ev.tenant, "a refused login has no tenant to attribute")
696 assert.NotEmpty(t, ev.detail["remote"])
697 assert.NotEmpty(t, ev.detail["reason"])
698 }
699
700 // TestGateAuditsRefusedTunnel pins the other refusal: an authenticated client
701 // that asks for something it may not have still leaves a row naming what it
702 // asked for.
703 func TestGateAuditsRefusedTunnel(t *testing.T) {
704 ca := newSigner(t)
705 tg, log := startGateAudited(t, ca.PublicKey(), true)
706 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
707
708 _, err := client.Dial("tcp", "default.nope:22")
709 require.Error(t, err)
710
711 ev := log.waitFor(t, "gate.tunnel.denied")
712 assert.Equal(t, "default", ev.tenant)
713 assert.Equal(t, "default.nope", ev.detail["target"])
714 assert.Equal(t, "unknown VM", ev.detail["reason"])
715 }
716
717 // TestGateShedsBeyondMaxStartups pins the aggregate pre-auth bound. The grace
718 // deadline caps how long one stalled client holds a slot; this caps how many,
719 // which is what stops a flood of merely-slow clients from crowding out logins.
720 func TestGateShedsBeyondMaxStartups(t *testing.T) {
721 prev := maxStartups
722 maxStartups = 2
723 t.Cleanup(func() { maxStartups = prev })
724
725 ca := newSigner(t)
726 tg, log := startGateAudited(t, ca.PublicKey(), true)
727
728 // Occupy every slot with clients that connect and then say nothing, so each
729 // sits in the handshake holding its slot until the grace expires.
730 var stalled []net.Conn
731 for range maxStartups {
732 c, err := net.Dial("tcp", tg.addr)
733 require.NoError(t, err)
734 stalled = append(stalled, c)
735 }
736 t.Cleanup(func() {
737 for _, c := range stalled {
738 _ = c.Close()
739 }
740 })
741
742 // The gate accepts asynchronously, so wait until the slots are actually held.
743 require.Eventually(t, func() bool { return len(tg.gateStarting()) == maxStartups },
744 2*time.Second, 5*time.Millisecond, "slots never filled")
745
746 // One more must be shed rather than queued. A shed connection is closed
747 // without a handshake, so it never sees the version banner an accepted one
748 // gets — and it happens at once, rather than after the grace window.
749 over, err := net.Dial("tcp", tg.addr)
750 require.NoError(t, err, "TCP still accepts; the gate sheds above the SSH layer")
751 defer over.Close()
752
753 require.NoError(t, over.SetReadDeadline(time.Now().Add(2*time.Second)))
754 _, err = over.Read(make([]byte, 1))
755 require.ErrorIs(t, err, io.EOF, "a shed connection is closed, not left in the handshake")
756
757 // Shedding must NOT write an audit row: it is the cheapest event to provoke
758 // on the gate, and a row per refusal would make the defence amplify the
759 // flood it exists to absorb.
760 log.mu.Lock()
761 defer log.mu.Unlock()
762 for _, e := range log.events {
763 assert.NotEqual(t, "gate.auth.denied", e.action, "the shed path must not audit")
764 }
765 }
766
767 // TestGateAuthenticatedConnDoesNotHoldStartupSlot is the other half of the
768 // bound: a tunnel may last hours, and must not occupy the pre-auth window while
769 // it does — or a busy gate would refuse logins it has every reason to take.
770 func TestGateAuthenticatedConnDoesNotHoldStartupSlot(t *testing.T) {
771 prev := maxStartups
772 maxStartups = 1
773 t.Cleanup(func() { maxStartups = prev })
774
775 ca := newSigner(t)
776 tg, _ := startGateAudited(t, ca.PublicKey(), true)
777
778 // Hold one authenticated connection open with a live tunnel.
779 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
780 conn, err := client.Dial("tcp", "default.vm1:22")
781 require.NoError(t, err)
782 defer conn.Close()
783
784 // With the only slot released at auth, a second login must still succeed.
785 second := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
786 _, err = second.Dial("tcp", "default.vm1:22")
787 require.NoError(t, err, "an established tunnel must not consume a startup slot")
788 }
internal/server/store/evolve.go
Old New
@@ -89,3 +89,82 @@ func dropColumn(db *sql.DB, table, column string) error {
89 slog.Info("dropped retired column", "table", table, "column", column) 89 slog.Info("dropped retired column", "table", table, "column", column)
90 return nil 90 return nil
91 } 91 }
92
93 // rekeyRevokedSSHCerts rebuilds revoked_ssh_certs with a PRIMARY KEY over
94 // (tenant, serial) instead of serial alone.
95 //
96 // The table shipped keyed on serial because revocation was enforced fleet-wide:
97 // one row denied a serial for everyone. Scoping enforcement to the revoking
98 // tenant makes that key wrong in a way that FAILS OPEN — with one row per
99 // serial globally, the first tenant to revoke a serial takes the row, and a
100 // second tenant revoking the same serial is silently dropped by the ON CONFLICT
101 // and its certificate keeps working. Per-tenant rows remove that possibility
102 // rather than making it unlikely.
103 //
104 // Idempotent: it inspects the existing key and returns immediately once the
105 // table is already in the new shape. Every row is carried across.
106 func rekeyRevokedSSHCerts(db *sql.DB) error {
107 keyed, err := pkColumns(db, "revoked_ssh_certs")
108 if err != nil {
109 return err
110 }
111 if len(keyed) == 0 || (len(keyed) == 2 && keyed[0] == "tenant" && keyed[1] == "serial") {
112 return nil // absent (fresh schema builds it right) or already rekeyed
113 }
114 // Rows a pre-tenant-column binary wrote were backfilled to 'default', and the
115 // default tenant is retired: no CA resolves to it, so once enforcement is
116 // scoped those rows deny nothing. Say so at the moment of the migration,
117 // where an operator rolling the release will see it, rather than leaving it
118 // to be discovered as a certificate that started working again.
119 var orphaned int
120 if err := db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE tenant='default'`).Scan(&orphaned); err == nil && orphaned > 0 {
121 slog.Warn("revocations filed under the retired 'default' tenant will no longer deny anything "+
122 "now that revocation is tenant-scoped; re-revoke them under the owning tenant",
123 "rows", orphaned)
124 }
125
126 tx, err := db.Begin()
127 if err != nil {
128 return err
129 }
130 defer tx.Rollback()
131 for _, stmt := range []string{
132 `CREATE TABLE revoked_ssh_certs_new (
133 tenant TEXT NOT NULL DEFAULT 'default',
134 serial INTEGER NOT NULL,
135 revoked_at DATETIME NOT NULL,
136 reason TEXT NOT NULL DEFAULT '',
137 PRIMARY KEY (tenant, serial)
138 )`,
139 `INSERT INTO revoked_ssh_certs_new(tenant, serial, revoked_at, reason)
140 SELECT tenant, serial, revoked_at, reason FROM revoked_ssh_certs`,
141 `DROP TABLE revoked_ssh_certs`,
142 `ALTER TABLE revoked_ssh_certs_new RENAME TO revoked_ssh_certs`,
143 } {
144 if _, err := tx.Exec(stmt); err != nil {
145 return fmt.Errorf("rekey revoked_ssh_certs: %w", err)
146 }
147 }
148 return tx.Commit()
149 }
150
151 // pkColumns returns the table's primary-key columns in key order, or nil when
152 // the table does not exist. table is a compile-time constant, the same rule
153 // ensureColumn states.
154 func pkColumns(db *sql.DB, table string) ([]string, error) {
155 rows, err := db.Query(`SELECT name, pk FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk`, table)
156 if err != nil {
157 return nil, fmt.Errorf("read primary key of %s: %w", table, err)
158 }
159 defer rows.Close()
160 var out []string
161 for rows.Next() {
162 var name string
163 var pk int
164 if err := rows.Scan(&name, &pk); err != nil {
165 return nil, err
166 }
167 out = append(out, name)
168 }
169 return out, rows.Err()
170 }
internal/server/store/evolve_test.go
Old New
@@ -1,6 +1,13 @@
1 package store 1 package store
2 2
3 import "testing" 3 import (
4 "database/sql"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
4 11
5 // ensureColumn must add a missing column exactly once and be a no-op after. 12 // ensureColumn must add a missing column exactly once and be a no-op after.
6 func TestEnsureColumnIdempotent(t *testing.T) { 13 func TestEnsureColumnIdempotent(t *testing.T) {
@@ -19,3 +26,77 @@ func TestEnsureColumnIdempotent(t *testing.T) {
19 t.Fatalf("email column count = %d, want 1", n) 26 t.Fatalf("email column count = %d, want 1", n)
20 } 27 }
21 } 28 }
29
30 // TestRekeyRevokedSSHCertsCarriesRowsAndIsIdempotent covers the migration that
31 // re-keys revocation on (tenant, serial). A revocation the plane already holds
32 // must survive it — losing one silently un-revokes a certificate.
33 func TestRekeyRevokedSSHCertsCarriesRowsAndIsIdempotent(t *testing.T) {
34 db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "old.db"))
35 require.NoError(t, err)
36 defer db.Close()
37
38 // The table as it shipped: keyed on serial alone, tenant added later by
39 // ensureColumn.
40 _, err = db.Exec(`CREATE TABLE revoked_ssh_certs (
41 serial INTEGER PRIMARY KEY,
42 revoked_at DATETIME NOT NULL,
43 reason TEXT NOT NULL DEFAULT ''
44 )`)
45 require.NoError(t, err)
46 require.NoError(t, ensureColumn(db, "revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"))
47 _, err = db.Exec(`INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason)
48 VALUES (7, 'alpha', '2026-01-01T00:00:00Z', 'leaked'), (9, 'beta', '2026-01-02T00:00:00Z', '')`)
49 require.NoError(t, err)
50
51 require.NoError(t, rekeyRevokedSSHCerts(db))
52
53 assert.Equal(t, []string{"tenant", "serial"}, mustPK(t, db), "key is now composite, in key order")
54
55 var n int
56 require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs`).Scan(&n))
57 assert.Equal(t, 2, n, "every revocation must survive the rebuild")
58 var reason string
59 require.NoError(t, db.QueryRow(`SELECT reason FROM revoked_ssh_certs WHERE tenant='alpha' AND serial=7`).Scan(&reason))
60 assert.Equal(t, "leaked", reason, "columns carry across, not just rows")
61
62 // Running again must be a no-op, since Open calls it on every boot.
63 require.NoError(t, rekeyRevokedSSHCerts(db))
64 require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs`).Scan(&n))
65 assert.Equal(t, 2, n)
66
67 // The point of the new key: two tenants may hold the same serial.
68 _, err = db.Exec(`INSERT INTO revoked_ssh_certs(tenant, serial, revoked_at, reason)
69 VALUES ('beta', 7, '2026-01-03T00:00:00Z', 'beta own cert')`)
70 require.NoError(t, err, "the old key would have rejected this and failed open")
71 }
72
73 func mustPK(t *testing.T, db *sql.DB) []string {
74 t.Helper()
75 cols, err := pkColumns(db, "revoked_ssh_certs")
76 require.NoError(t, err)
77 return cols
78 }
79
80 // TestRekeyWarnsOnRetiredDefaultTenantRows pins that the migration notices
81 // revocations it is about to render inert. Scoping enforcement means rows filed
82 // under the retired 'default' tenant stop denying anything, and a certificate
83 // quietly working again is the one outcome nobody would spot.
84 func TestRekeyWarnsOnRetiredDefaultTenantRows(t *testing.T) {
85 db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "legacy.db"))
86 require.NoError(t, err)
87 defer db.Close()
88 _, err = db.Exec(`CREATE TABLE revoked_ssh_certs (
89 serial INTEGER PRIMARY KEY, revoked_at DATETIME NOT NULL, reason TEXT NOT NULL DEFAULT '')`)
90 require.NoError(t, err)
91 require.NoError(t, ensureColumn(db, "revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"))
92 _, err = db.Exec(`INSERT INTO revoked_ssh_certs(serial, revoked_at) VALUES (5, '2026-01-01T00:00:00Z')`)
93 require.NoError(t, err)
94
95 require.NoError(t, rekeyRevokedSSHCerts(db))
96
97 // The row is carried across rather than dropped — it is evidence, and an
98 // operator may want to re-file it under the owning tenant.
99 var tenant string
100 require.NoError(t, db.QueryRow(`SELECT tenant FROM revoked_ssh_certs WHERE serial=5`).Scan(&tenant))
101 assert.Equal(t, "default", tenant)
102 }
internal/server/store/sessions.go
Old New
@@ -13,12 +13,17 @@ import (
13 // and returns its id — a 256-bit random hex string used verbatim as the 13 // and returns its id — a 256-bit random hex string used verbatim as the
14 // eitri_session cookie value. Server-side rows mean revocation works and a 14 // eitri_session cookie value. Server-side rows mean revocation works and a
15 // restart keeps users signed in. 15 // restart keeps users signed in.
16 //
17 // Only the SHA-256 of the id is stored, exactly as a PAT is (see hashToken):
18 // the row is a verifier, not a credential, so a copied database or a backup
19 // yields nothing anyone can present. The cookie is unchanged — the client still
20 // holds the id itself, which is the only place it exists.
16 func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) { 21 func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) {
17 id := random.Hex(32) 22 id := random.Hex(32)
18 now := time.Now().UTC() 23 now := time.Now().UTC()
19 _, err := s.db.Exec( 24 _, err := s.db.Exec(
20 `INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`, 25 `INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`,
21 id, tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339), 26 hashToken(id), tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339),
22 ) 27 )
23 if err != nil { 28 if err != nil {
24 return "", fmt.Errorf("create session: %w", err) 29 return "", fmt.Errorf("create session: %w", err)
@@ -29,11 +34,21 @@ func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error)
29 // SessionTenant resolves a session id to its tenant, enforcing expiry on read. 34 // SessionTenant resolves a session id to its tenant, enforcing expiry on read.
30 // ok=false (no error) for an unknown, deleted, or expired session — the caller 35 // ok=false (no error) for an unknown, deleted, or expired session — the caller
31 // treats all three identically (redirect to sign-in). 36 // treats all three identically (redirect to sign-in).
37 //
38 // Keyed on the hash of the presented id, so the stored value is never compared
39 // as a secret and there is no timing side-channel to work.
40 //
41 // Rows written before ids were hashed hold the id itself. They cannot match a
42 // hashed lookup, so they authenticate nobody and simply age out at their own
43 // expiry — the one visible effect of the change is that everyone signs in once
44 // more. They are not deleted here because a stored id and a stored hash are both
45 // 64 hex characters and cannot be told apart, so a blanket purge would sign
46 // every user out on every restart rather than once.
32 func (s *Store) SessionTenant(id string) (string, bool, error) { 47 func (s *Store) SessionTenant(id string) (string, bool, error) {
33 var tenant string 48 var tenant string
34 err := s.db.QueryRow( 49 err := s.db.QueryRow(
35 `SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`, 50 `SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`,
36 id, time.Now().UTC().Format(time.RFC3339), 51 hashToken(id), time.Now().UTC().Format(time.RFC3339),
37 ).Scan(&tenant) 52 ).Scan(&tenant)
38 if errors.Is(err, sql.ErrNoRows) { 53 if errors.Is(err, sql.ErrNoRows) {
39 return "", false, nil 54 return "", false, nil
@@ -45,8 +60,9 @@ func (s *Store) SessionTenant(id string) (string, bool, error) {
45 } 60 }
46 61
47 // DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op. 62 // DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op.
63 // Keyed on the hash, like every other read of this table.
48 func (s *Store) DeleteSession(id string) error { 64 func (s *Store) DeleteSession(id string) error {
49 if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, id); err != nil { 65 if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, hashToken(id)); err != nil {
50 return fmt.Errorf("delete session: %w", err) 66 return fmt.Errorf("delete session: %w", err)
51 } 67 }
52 return nil 68 return nil
internal/server/store/sessions_test.go
Old New
@@ -68,3 +68,38 @@ func TestReapSessions(t *testing.T) {
68 require.NoError(t, err) 68 require.NoError(t, err)
69 assert.Equal(t, int64(0), n) 69 assert.Equal(t, int64(0), n)
70 } 70 }
71
72 // TestSessionIDNotStoredInCleartext is the property, not the plumbing: a copied
73 // database must not hand anyone a usable console session. The lifecycle tests
74 // above would pass just as well with the id stored verbatim.
75 func TestSessionIDNotStoredInCleartext(t *testing.T) {
76 s := newStore(t)
77 id, err := s.CreateSession(testTenant, time.Hour)
78 require.NoError(t, err)
79
80 var stored string
81 require.NoError(t, s.db.QueryRow(`SELECT id FROM sessions`).Scan(&stored))
82 assert.NotEqual(t, id, stored, "the cookie value must not be what is on disk")
83 assert.Equal(t, hashToken(id), stored)
84
85 // And the stored value must not itself work as a cookie — otherwise the row
86 // is still a bearer credential, just a differently-spelled one.
87 _, ok, err := s.SessionTenant(stored)
88 require.NoError(t, err)
89 assert.False(t, ok, "presenting the stored hash must not authenticate")
90 }
91
92 // TestSessionRowsPredatingHashingAreInert pins what happens to sessions written
93 // before ids were hashed: they authenticate nobody, rather than still working.
94 func TestSessionRowsPredatingHashingAreInert(t *testing.T) {
95 s := newStore(t)
96 legacy := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
97 _, err := s.db.Exec(`INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`,
98 legacy, testTenant, time.Now().UTC().Format(time.RFC3339),
99 time.Now().UTC().Add(time.Hour).Format(time.RFC3339))
100 require.NoError(t, err)
101
102 _, ok, err := s.SessionTenant(legacy)
103 require.NoError(t, err)
104 assert.False(t, ok, "a pre-hashing row must not authenticate")
105 }
internal/server/store/store.go
Old New
@@ -217,15 +217,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS vms_tenant_name ON vms(tenant, name) WHERE del
217 -- lookups are preserved. The tenant column (added via ensureColumn in Open — the 217 -- lookups are preserved. The tenant column (added via ensureColumn in Open — the
218 -- table shipped without it) scopes the revocation LIST per tenant and records 218 -- table shipped without it) scopes the revocation LIST per tenant and records
219 -- which tenant filed the revocation; old rows backfill to 'default'. Enforcement 219 -- which tenant filed the revocation; old rows backfill to 'default'. Enforcement
220 -- at the GATE stays fleet-wide by serial (IsSSHCertRevoked ignores tenant): a 220 -- at the GATE is scoped to the revoking tenant: the gate resolves a presented
221 -- revocation only ever DENIES access, serials are 64-bit crypto-random, and the 221 -- certificate to the tenant that registered its signing CA, and consults that
222 -- gate has no user-cert mint registry to key a tenant off, so a global kill by 222 -- tenant's rows only. A fleet-wide kill by serial was fail-safe for the guest it
223 -- serial is fail-safe. Guests trust the CA with no guest-side KRL — a 223 -- protected and an availability hole for everyone else — any tenant could write
224 -- multi-user/rotation follow-up. 224 -- a row that denied a serial it had no claim to. The key is (tenant, serial) so
225 -- two tenants can revoke the same serial independently; keyed on serial alone
226 -- the second revocation would be dropped and its certificate keep working.
227 -- Guests trust the CA with no guest-side KRL — a multi-user/rotation follow-up.
225 CREATE TABLE IF NOT EXISTS revoked_ssh_certs ( 228 CREATE TABLE IF NOT EXISTS revoked_ssh_certs (
226 serial INTEGER PRIMARY KEY, 229 tenant TEXT NOT NULL DEFAULT 'default',
230 serial INTEGER NOT NULL,
227 revoked_at DATETIME NOT NULL, 231 revoked_at DATETIME NOT NULL,
228 reason TEXT NOT NULL DEFAULT '' 232 reason TEXT NOT NULL DEFAULT '',
233 PRIMARY KEY (tenant, serial)
229 ); 234 );
230 235
231 -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds 236 -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds
@@ -413,6 +418,13 @@ func Open(path, cidrPool string) (*Store, error) {
413 return nil, err 418 return nil, err
414 } 419 }
415 420
421 // Revocation became tenant-scoped; the old key would silently drop a second
422 // tenant's revocation of the same serial. See rekeyRevokedSSHCerts.
423 if err := rekeyRevokedSSHCerts(db); err != nil {
424 db.Close()
425 return nil, err
426 }
427
416 // A guest's host private key belongs to its host, so there is no column for 428 // A guest's host private key belongs to its host, so there is no column for
417 // one. A database written before that was true has both the column and the 429 // one. A database written before that was true has both the column and the
418 // keys in it; dropping the column takes them with it. The certificate beside 430 // keys in it; dropping the column takes them with it. The certificate beside
@@ -1161,7 +1173,7 @@ type RevokedCert struct {
1161 func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error { 1173 func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error {
1162 _, err := s.db.Exec( 1174 _, err := s.db.Exec(
1163 `INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason) VALUES (?, ?, ?, ?) 1175 `INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason) VALUES (?, ?, ?, ?)
1164 ON CONFLICT(serial) DO NOTHING`, 1176 ON CONFLICT(tenant, serial) DO NOTHING`,
1165 int64(serial), tenant, time.Now().UTC().Format(time.RFC3339), reason, 1177 int64(serial), tenant, time.Now().UTC().Format(time.RFC3339), reason,
1166 ) 1178 )
1167 if err != nil { 1179 if err != nil {
@@ -1170,12 +1182,18 @@ func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error
1170 return nil 1182 return nil
1171 } 1183 }
1172 1184
1173 // IsSSHCertRevoked reports whether serial is on the revocation list. The gate 1185 // IsSSHCertRevoked reports whether tenant has revoked serial. The gate consults
1174 // consults this on every cert authentication, so it is a hot read; the single 1186 // this on every cert authentication, so it is a hot read; the composite PRIMARY
1175 // PRIMARY KEY lookup is cheap. 1187 // KEY makes it a single index lookup.
1176 func (s *Store) IsSSHCertRevoked(serial uint64) (bool, error) { 1188 //
1189 // Scoped on purpose: a certificate is revoked by the tenant whose CA signed it,
1190 // and only for that tenant. Answering fleet-wide would let any tenant deny a
1191 // serial belonging to another — the API cannot attribute a bare serial, because
1192 // CAs are BYO and eitri never sees the certificates they mint.
1193 func (s *Store) IsSSHCertRevoked(tenant string, serial uint64) (bool, error) {
1177 var n int 1194 var n int
1178 err := s.db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE serial=?`, int64(serial)).Scan(&n) 1195 err := s.db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE tenant=? AND serial=?`,
1196 tenant, int64(serial)).Scan(&n)
1179 if err != nil { 1197 if err != nil {
1180 return false, fmt.Errorf("lookup revoked ssh cert: %w", err) 1198 return false, fmt.Errorf("lookup revoked ssh cert: %w", err)
1181 } 1199 }
internal/server/store/store_test.go
Old New
@@ -456,18 +456,18 @@ func TestSSHCertRevocation(t *testing.T) {
456 s := newStore(t) 456 s := newStore(t)
457 457
458 // Unknown serial is not revoked. 458 // Unknown serial is not revoked.
459 revoked, err := s.IsSSHCertRevoked(42) 459 revoked, err := s.IsSSHCertRevoked(testTenant, 42)
460 require.NoError(t, err) 460 require.NoError(t, err)
461 assert.False(t, revoked) 461 assert.False(t, revoked)
462 462
463 // Revoke, then it reads back as revoked. 463 // Revoke, then it reads back as revoked.
464 require.NoError(t, s.RevokeSSHCert(testTenant, 42, "leaked laptop")) 464 require.NoError(t, s.RevokeSSHCert(testTenant, 42, "leaked laptop"))
465 revoked, err = s.IsSSHCertRevoked(42) 465 revoked, err = s.IsSSHCertRevoked(testTenant, 42)
466 require.NoError(t, err) 466 require.NoError(t, err)
467 assert.True(t, revoked) 467 assert.True(t, revoked)
468 468
469 // A different serial is unaffected. 469 // A different serial is unaffected.
470 revoked, err = s.IsSSHCertRevoked(43) 470 revoked, err = s.IsSSHCertRevoked(testTenant, 43)
471 require.NoError(t, err) 471 require.NoError(t, err)
472 assert.False(t, revoked) 472 assert.False(t, revoked)
473 473
@@ -503,19 +503,35 @@ func TestSSHCertRevocationTenantScoped(t *testing.T) {
503 require.Len(t, bl, 1) 503 require.Len(t, bl, 1)
504 assert.Equal(t, uint64(200), bl[0].Serial) 504 assert.Equal(t, uint64(200), bl[0].Serial)
505 505
506 // Gate enforcement is fleet-wide by serial: both are revoked regardless of tenant. 506 // Gate enforcement is scoped to the revoking tenant: each serial is revoked
507 for _, serial := range []uint64{100, 200} { 507 // for its own tenant and live for the other. A fleet-wide answer here would
508 revoked, err := s.IsSSHCertRevoked(serial) 508 // let either tenant deny a serial it has no claim to.
509 for _, c := range []struct {
510 tenant string
511 serial uint64
512 want bool
513 }{
514 {testTenant, 100, true},
515 {testTenant, 200, false},
516 {beta.ID, 200, true},
517 {beta.ID, 100, false},
518 } {
519 revoked, err := s.IsSSHCertRevoked(c.tenant, c.serial)
509 require.NoError(t, err) 520 require.NoError(t, err)
510 assert.True(t, revoked, "serial %d must read revoked at the gate", serial) 521 assert.Equal(t, c.want, revoked, "tenant %s serial %d", c.tenant, c.serial)
511 } 522 }
512 523
513 // beta re-filing default's serial is a no-op: original owner (default) keeps it. 524 // Two tenants may revoke the SAME serial independently — the rows are keyed
514 require.NoError(t, s.RevokeSSHCert(beta.ID, 100, "beta tries to steal")) 525 // (tenant, serial). Keyed on serial alone the second would be dropped and
526 // that tenant's certificate would keep working: a revocation that fails open.
527 require.NoError(t, s.RevokeSSHCert(beta.ID, 100, "beta's own cert, same serial"))
528 revoked, err := s.IsSSHCertRevoked(beta.ID, 100)
529 require.NoError(t, err)
530 assert.True(t, revoked, "beta's revocation must bind beta")
531
515 bl, err = s.ListRevokedSSHCerts(beta.ID) 532 bl, err = s.ListRevokedSSHCerts(beta.ID)
516 require.NoError(t, err) 533 require.NoError(t, err)
517 require.Len(t, bl, 1, "beta must not acquire a serial default already owns") 534 assert.Len(t, bl, 2, "beta now owns rows for both serials it revoked")
518 assert.Equal(t, uint64(200), bl[0].Serial)
519 } 535 }
520 536
521 // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial 537 // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial
@@ -528,12 +544,12 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) {
528 const other = uint64(0x8000000000000000) 544 const other = uint64(0x8000000000000000)
529 545
530 require.NoError(t, s.RevokeSSHCert(testTenant, big, "big")) 546 require.NoError(t, s.RevokeSSHCert(testTenant, big, "big"))
531 revoked, err := s.IsSSHCertRevoked(big) 547 revoked, err := s.IsSSHCertRevoked(testTenant, big)
532 require.NoError(t, err) 548 require.NoError(t, err)
533 assert.True(t, revoked) 549 assert.True(t, revoked)
534 550
535 // A distinct large serial must not collide with the first. 551 // A distinct large serial must not collide with the first.
536 revoked, err = s.IsSSHCertRevoked(other) 552 revoked, err = s.IsSSHCertRevoked(testTenant, other)
537 require.NoError(t, err) 553 require.NoError(t, err)
538 assert.False(t, revoked) 554 assert.False(t, revoked)
539 555
internal/server/web/csp.go
Old New
@@ -0,0 +1,58 @@
1 package web
2
3 import (
4 "crypto/sha256"
5 "encoding/base64"
6 "io/fs"
7 "regexp"
8 "strings"
9 "sync"
10 )
11
12 // inlineScript matches a <script> element that carries its code inline. One with
13 // a src= attribute is skipped: it loads from 'self' and needs no hash.
14 var inlineScript = regexp.MustCompile(`(?s)<script([^>]*)>(.*?)</script>`)
15
16 var (
17 hashOnce sync.Once
18 hashes []string
19 )
20
21 // InlineScriptHashes returns CSP source tokens ("sha256-…") covering every
22 // inline script in the built index.html.
23 //
24 // The SPA's entry point is one inline block SvelteKit writes at build time, and
25 // its contents change on every build (it names a fresh global). Hashing the
26 // bytes actually embedded — rather than pinning a literal or giving up and
27 // allowing 'unsafe-inline' — keeps script-src strict across rebuilds with
28 // nothing for an operator to remember to update.
29 //
30 // A hash present in script-src is also what makes CSP ignore 'unsafe-inline',
31 // so this is the difference between a policy that blocks injected script and
32 // one that only looks like it does.
33 func InlineScriptHashes() []string {
34 hashOnce.Do(func() { hashes = scanInlineScripts(dist, "dist/index.html") })
35 return hashes
36 }
37
38 // scanInlineScripts is the testable half: it takes the filesystem and path so a
39 // test can hash a document it wrote itself. A missing or unreadable index.html
40 // yields no hashes — the UI is not built, and there is nothing to allow.
41 func scanInlineScripts(fsys fs.FS, path string) []string {
42 raw, err := fs.ReadFile(fsys, path)
43 if err != nil {
44 return nil
45 }
46 var out []string
47 for _, m := range inlineScript.FindAllSubmatch(raw, -1) {
48 if strings.Contains(strings.ToLower(string(m[1])), "src=") {
49 continue
50 }
51 if len(m[2]) == 0 {
52 continue
53 }
54 sum := sha256.Sum256(m[2])
55 out = append(out, "sha256-"+base64.StdEncoding.EncodeToString(sum[:]))
56 }
57 return out
58 }
internal/server/web/csp_test.go
Old New
@@ -0,0 +1,52 @@
1 package web
2
3 import (
4 "testing"
5 "testing/fstest"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 func TestScanInlineScriptsHashesOnlyInlineCode(t *testing.T) {
12 fsys := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte(
13 `<html><head><script src="/app.js"></script></head>` +
14 `<body><script>console.log("hi")</script></body></html>`)}}
15 got := scanInlineScripts(fsys, "index.html")
16 // sha256 of `console.log("hi")`, base64 — verified with openssl, which is the
17 // same computation a browser makes.
18 require.Len(t, got, 1, "the src= script must not be hashed")
19 assert.Equal(t, "sha256-TMFma7PHrBUjZEUKY/MwBLuX3/HrQe2+A1FmjMS7ppA=", got[0])
20 }
21
22 func TestScanInlineScriptsMissingIndexYieldsNone(t *testing.T) {
23 assert.Empty(t, scanInlineScripts(fstest.MapFS{}, "index.html"))
24 }
25
26 // TestInlineScriptHashesCoversBuiltSPA guards the real embedded asset: if the
27 // console ships with an inline entry point, it must be hashed, or a strict
28 // script-src would blank the page.
29 func TestInlineScriptHashesCoversBuiltSPA(t *testing.T) {
30 if !fileExists(dist, "dist/index.html") {
31 t.Skip("UI not built")
32 }
33 assert.NotEmpty(t, InlineScriptHashes(), "built SPA has an inline entry point")
34 }
35
36 func TestScanInlineScriptsSkipsEmptyBlock(t *testing.T) {
37 // An empty <script></script> hashes to the digest of nothing, which would be
38 // a source token that permits an empty script and confuses the policy.
39 fsys := fstest.MapFS{"index.html": &fstest.MapFile{
40 Data: []byte(`<html><body><script></script></body></html>`)}}
41 assert.Empty(t, scanInlineScripts(fsys, "index.html"))
42 }
43
44 func TestScanInlineScriptsHandlesAttributedScript(t *testing.T) {
45 // SvelteKit writes a bare <script>, but a build that emits type="module"
46 // must still be covered — a missed hash blanks the console.
47 fsys := fstest.MapFS{"index.html": &fstest.MapFile{
48 Data: []byte(`<html><body><script type="module">console.log("hi")</script></body></html>`)}}
49 got := scanInlineScripts(fsys, "index.html")
50 require.Len(t, got, 1)
51 assert.Equal(t, "sha256-TMFma7PHrBUjZEUKY/MwBLuX3/HrQe2+A1FmjMS7ppA=", got[0])
52 }
scripts/backup-image.sh
Old New
@@ -0,0 +1,30 @@
1 #!/usr/bin/env bash
2 # Build and push the nightly-backup image (see deploy/server/backup.Dockerfile).
3 #
4 # Unlike the server and site images this one is NOT built per release: it holds
5 # sqlite and nothing of eitri's, so it carries its own tag and is rebuilt only
6 # when the base image moves. BACKUP_IMAGE therefore includes the tag.
7 #
8 # Reads deploy.env (same file scripts/deploy.sh uses):
9 # BACKUP_IMAGE full ref to push, e.g. registry.example/eitri-backup:1
10 set -euo pipefail
11 cd "$(dirname "$0")/.."
12
13 ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}"
14 # shellcheck disable=SC1090
15 [ -f "$ENV_FILE" ] && . "$ENV_FILE"
16 : "${BACKUP_IMAGE:?backup-image: set BACKUP_IMAGE (with its tag) in $ENV_FILE}"
17
18 case "$BACKUP_IMAGE" in
19 *:*) ;;
20 *) echo "backup-image: BACKUP_IMAGE must include a tag, e.g. .../eitri-backup:1" >&2; exit 1 ;;
21 esac
22
23 # arm64: the control-plane node this job is pinned to (see plane.*.env NODE_NAME).
24 docker build --platform linux/arm64 -f deploy/server/backup.Dockerfile -t "$BACKUP_IMAGE" .
25 # Between build and push, like the other two: the gate is on what gets published.
26 ./scripts/scan-image.sh "$BACKUP_IMAGE"
27 docker push "$BACKUP_IMAGE"
28 echo "backup-image: pushed $BACKUP_IMAGE — set BACKUP_IMAGE in the plane's ship.env and re-ship, or"
29 echo " kubectl -n <ns> patch cronjob eitri-server-backup --type=json \\"
30 echo " -p='[{\"op\":\"replace\",\"path\":\"/spec/jobTemplate/spec/template/spec/containers/0/image\",\"value\":\"$BACKUP_IMAGE\"}]'"
scripts/scan-image.sh
Old New
@@ -0,0 +1,47 @@
1 #!/usr/bin/env bash
2 # Scan a built container image for known OS/library vulnerabilities, and fail
3 # on the severities we refuse to publish.
4 #
5 # This is the gate the Go scanner cannot be. `make vuln` (govulncheck) covers
6 # the module graph, which is the whole of the server image's contents — it is a
7 # static binary on distroless. The site image is nginx:alpine, and that layer
8 # has a package manifest with its own advisories that nothing else here reads.
9 #
10 # Runs trivy AS A CONTAINER, so there is no scanner to install and no second
11 # toolchain to keep pinned. The image is handed over as a tarball rather than
12 # through a daemon socket: podman and docker disagree about where that socket
13 # is and whether it exists, and `save` works identically on both.
14 #
15 # scripts/scan-image.sh <image:tag>
16 #
17 # SCAN_SEVERITY severities that fail (default HIGH,CRITICAL)
18 # SKIP_IMAGE_SCAN=1 skip, loudly — for an offline build, never as a habit
19 # TRIVY_IMAGE pinned scanner image
20 set -euo pipefail
21
22 IMAGE="${1:?scan-image: usage: scan-image.sh <image:tag>}"
23 SEVERITY="${SCAN_SEVERITY:-HIGH,CRITICAL}"
24 TRIVY_IMAGE="${TRIVY_IMAGE:-docker.io/aquasec/trivy:0.58.1}"
25 DOCKER="$(command -v docker || command -v podman)"
26
27 if [ "${SKIP_IMAGE_SCAN:-}" = "1" ]; then
28 echo "scan-image: SKIPPED for $IMAGE (SKIP_IMAGE_SCAN=1) — publishing unscanned" >&2
29 exit 0
30 fi
31
32 TAR="$(mktemp -d)/image.tar"
33 trap 'rm -rf "$(dirname "$TAR")"' EXIT
34 echo "scan-image: scanning $IMAGE for $SEVERITY"
35 "$DOCKER" save "$IMAGE" -o "$TAR"
36
37 # The vulnerability DB is cached in a named volume so a second scan in the same
38 # session does not re-download it.
39 "$DOCKER" run --rm \
40 -v "$TAR:/image.tar:ro" \
41 -v eitri-trivy-cache:/root/.cache/trivy \
42 "$TRIVY_IMAGE" image \
43 --input /image.tar \
44 --severity "$SEVERITY" \
45 --ignore-unfixed \
46 --exit-code 1 \
47 --scanners vuln
scripts/server-image.sh
Old New
@@ -33,5 +33,8 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \
33 cp deploy/server/Dockerfile "$BUILD_DIR/" 33 cp deploy/server/Dockerfile "$BUILD_DIR/"
34 34
35 docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR" 35 docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR"
36 # Between build and push on purpose: the gate is on what gets PUBLISHED, not on
37 # what someone happened to build locally.
38 ./scripts/scan-image.sh "$SERVER_IMAGE:$VERSION"
36 docker push "$SERVER_IMAGE:$VERSION" 39 docker push "$SERVER_IMAGE:$VERSION"
37 echo "server-image: pushed $SERVER_IMAGE:$VERSION — roll it out with kubectl -n eitri set image deployment/eitri-server eitri-server=$SERVER_IMAGE:$VERSION" 40 echo "server-image: pushed $SERVER_IMAGE:$VERSION — roll it out with kubectl -n eitri set image deployment/eitri-server eitri-server=$SERVER_IMAGE:$VERSION"
scripts/ship.env.example
Old New
@@ -13,6 +13,9 @@
13 # plane's registry is named. 13 # plane's registry is named.
14 SERVER_IMAGE="registry.example.com/eitri-server" 14 SERVER_IMAGE="registry.example.com/eitri-server"
15 SITE_IMAGE="registry.example.com/eitri-site" 15 SITE_IMAGE="registry.example.com/eitri-site"
16 # The nightly backup job's image (scripts/backup-image.sh). Required only on a
17 # plane with BACKUPS=1. It includes its OWN tag — it is not built per release.
18 BACKUP_IMAGE="registry.example.com/eitri-backup:1"
16 # Image platform for the site image; match the node arch in the plane file. 19 # Image platform for the site image; match the node arch in the plane file.
17 SITE_PLATFORM="linux/arm64" 20 SITE_PLATFORM="linux/arm64"
18 21
scripts/ship.sh
Old New
@@ -102,6 +102,13 @@ RENDER_VARS=(
102 CONFIG_SECRET PVC_NAME PVC_SIZE TLS_SECRET SITE_TLS_SECRET 102 CONFIG_SECRET PVC_NAME PVC_SIZE TLS_SECRET SITE_TLS_SECRET
103 SERVER_IMAGE SITE_IMAGE TAG 103 SERVER_IMAGE SITE_IMAGE TAG
104 ) 104 )
105 # The backup image is referenced only by the CronJob, which is applied only
106 # where BACKUPS=1 — so a plane without backups never has to name one. It carries
107 # its own tag rather than $TAG: it holds sqlite and nothing of eitri's, and
108 # rebuilding it every release would be work with no output.
109 if [[ "${BACKUPS:-0}" == "1" ]]; then
110 RENDER_VARS+=(BACKUP_IMAGE)
111 fi
105 # The bundled issuer's values join the list only on a plane that runs one, so a 112 # The bundled issuer's values join the list only on a plane that runs one, so a
106 # plane without it never carries them. An ${OIDC_*} reference added to a SHARED 113 # plane without it never carries them. An ${OIDC_*} reference added to a SHARED
107 # template later would then survive rendering as literal text and fail the apply 114 # template later would then survive rendering as literal text and fail the apply
scripts/site-image.sh
Old New
@@ -35,5 +35,9 @@ cp -r "dist/$DL_VERSION" "site/dist/dl/$DL_VERSION"
35 ln -sfn "$DL_VERSION" site/dist/dl/latest 35 ln -sfn "$DL_VERSION" site/dist/dl/latest
36 36
37 docker build ${SITE_PLATFORM:+--platform "$SITE_PLATFORM"} -f site/Dockerfile -t "$SITE_IMAGE:$VERSION" . 37 docker build ${SITE_PLATFORM:+--platform "$SITE_PLATFORM"} -f site/Dockerfile -t "$SITE_IMAGE:$VERSION" .
38 # Between build and push on purpose: the gate is on what gets PUBLISHED. This
39 # image is the one that needs it — nginx:alpine carries an OS package manifest,
40 # which is a surface `make vuln` cannot see.
41 ./scripts/scan-image.sh "$SITE_IMAGE:$VERSION"
38 docker push "$SITE_IMAGE:$VERSION" 42 docker push "$SITE_IMAGE:$VERSION"
39 echo "site-image: pushed $SITE_IMAGE:$VERSION serving /dl/$DL_VERSION — roll it out on k8s manually" 43 echo "site-image: pushed $SITE_IMAGE:$VERSION serving /dl/$DL_VERSION — roll it out on k8s manually"