carlos / platform Public

host: carlos-app@ units can no longer reach IMDS; host_role drop-ins re-open it

The tenant template hardens well but set no IPAddressDeny, so on a box
without the nftables skuid gate (tito, issue #351) any app process could
call 169.254.169.254 and take the box instance-role credentials. Apps ride
per-app scoped IAM keys delivered via env and have no IMDS need — deny the
metadata address (v4 and v6) unit-wide in the template.

The one legitimate IMDS consumer under this template is a host_role
tenant, whose AWS clients would otherwise be starved SILENTLY (the F1
failure mode the adopt advisory exists for). Its rendered drop-in now
emits a matching IPAddressAllow — in systemd's BPF firewall an allow-list
match wins over the deny list — while the operator-owned skuid gate keeps
the last word on hardened boxes.

Closes #351.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLbET3qVmgjwRMnpZRRZmg
Paul Campbell pushed by paul@keymail.dev b94d626d5f9eb5d140badf55a77179615dd87ee0 parent d6cd0e9
7 files changed, +39 −5
  • internal/caps/converge_test.go +1 −1
  • internal/caps/render.go +8 −0
  • internal/caps/render_test.go +2 −2
  • internal/caps/testdata/console.service.conf +1 −0
  • internal/caps/testdata/keymail.service.conf +1 −0
  • internal/host/host_test.go +18 −0
  • internal/host/units/carlos-app@.service +8 −2

CI passed — run details

diff --git a/internal/caps/converge_test.go b/internal/caps/converge_test.go
index 266b1dbf6..88bd03506 100644
--- a/internal/caps/converge_test.go
+++ b/internal/caps/converge_test.go
@@ -374,7 +374,7 @@ func TestGrantRevokedRewritesTheDropIn(t *testing.T) {
svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
mustNotExist(t, sockPath)
svc := string(readFile(t, svcPath))
- for _, gone := range []string{"DynamicUser=no", "User=carlos-keymail", "Group=carlos-keymail"} {
+ for _, gone := range []string{"DynamicUser=no", "User=carlos-keymail", "Group=carlos-keymail", "IPAddressAllow"} {
if strings.Contains(svc, gone) {
t.Fatalf("service drop-in still holds %q after the revoke:\n%s", gone, svc)
}
diff --git a/internal/caps/render.go b/internal/caps/render.go
index 2cdadc905..34e524285 100644
--- a/internal/caps/render.go
+++ b/internal/caps/render.go
@@ -106,6 +106,14 @@ func RenderService(app string, s *Set, g Grants) ([]byte, error) {
"RestrictSUIDSGID=yes",
"User="+user,
"Group="+user,
+ // The template denies IMDS unit-wide (issue #351); a matching
+ // IPAddressAllow re-opens it for exactly the tenant that holds
+ // the grant — in systemd's BPF firewall an allow-list match
+ // wins over the deny list. The nftables skuid gate stays the
+ // operator-owned authority on which uids reach the role
+ // (spec §4b); this only lifts the unit-level fence the
+ // template put in everyone's way.
+ "IPAddressAllow=169.254.169.254/32 fd00:ec2::254/128",
)
}
diff --git a/internal/caps/render_test.go b/internal/caps/render_test.go
index 870a5defd..e8f67a3e5 100644
--- a/internal/caps/render_test.go
+++ b/internal/caps/render_test.go
@@ -171,7 +171,7 @@ func TestUngrantedCapabilitiesMaterializeNothing(t *testing.T) {
t.Errorf("service body missing self-service line %q:\n%s", want, got)
}
}
- for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group="} {
+ for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group=", "IPAddressAllow"} {
if bytes.Contains(got, []byte(unwanted)) {
t.Errorf("service body has ungranted host_role directive %q:\n%s", unwanted, got)
}
@@ -202,7 +202,7 @@ func TestHostRoleGrantLingersButManifestNoLongerDeclaresFallsBackToDynamicUser(t
t.Errorf("service body missing %q:\n%s", want, got)
}
}
- for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group="} {
+ for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group=", "IPAddressAllow"} {
if bytes.Contains(got, []byte(unwanted)) {
t.Errorf("service body has host_role directive %q though the manifest no longer declares host_role:\n%s", unwanted, got)
}
diff --git a/internal/caps/testdata/console.service.conf b/internal/caps/testdata/console.service.conf
index bf1d4c8f6..8eeb03948 100644
--- a/internal/caps/testdata/console.service.conf
+++ b/internal/caps/testdata/console.service.conf
@@ -4,3 +4,4 @@ DynamicUser=no
RestrictSUIDSGID=yes
User=carlos-console
Group=carlos-console
+IPAddressAllow=169.254.169.254/32 fd00:ec2::254/128
diff --git a/internal/caps/testdata/keymail.service.conf b/internal/caps/testdata/keymail.service.conf
index 766f71f78..47afc8273 100644
--- a/internal/caps/testdata/keymail.service.conf
+++ b/internal/caps/testdata/keymail.service.conf
@@ -12,3 +12,4 @@ DynamicUser=no
RestrictSUIDSGID=yes
User=carlos-keymail
Group=carlos-keymail
+IPAddressAllow=169.254.169.254/32 fd00:ec2::254/128
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 6f442c850..a5711aee1 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -1356,6 +1356,24 @@ func shipVersion(t *testing.T, s release.Store, acct, app, version string) {
//
// This is a one-line property that would regress silently (the drill still
// passes, right up until a tenant grows), so it is pinned here.
+// The tenant template must deny IMDS at the unit level (issue #351): the
+// nftables skuid gate is operator territory and a box can simply not have
+// one (tito didn't), at which point any app process could read the box
+// instance-role credentials off 169.254.169.254. Apps get per-app scoped
+// IAM keys via env; the one legitimate IMDS consumer under this template
+// is a host_role tenant, whose drop-in re-opens it with IPAddressAllow
+// (allow beats deny in systemd's BPF firewall) — see caps.RenderService.
+// One-line property, silent regression, so it is pinned here.
+func TestAppTemplateDeniesIMDS(t *testing.T) {
+ body, err := unitsFS.ReadFile("units/carlos-app@.service")
+ if err != nil {
+ t.Fatalf("carlos-app@.service: %v", err)
+ }
+ if !strings.Contains(string(body), "IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128") {
+ t.Errorf("carlos-app@.service does not deny IMDS — app code on an ungated box can take the instance role's credentials (issue #351)")
+ }
+}
+
func TestVerifyDrillsRestoreToDiskNotTmpfs(t *testing.T) {
for _, unit := range []string{"carlos-restore-verify.service", "carlos-hibernate-verify.service"} {
body, err := unitsFS.ReadFile("units/" + unit)
diff --git a/internal/host/units/carlos-app@.service b/internal/host/units/carlos-app@.service
index 2fc9dde79..4ab0a595c 100644
--- a/internal/host/units/carlos-app@.service
+++ b/internal/host/units/carlos-app@.service
@@ -6,8 +6,13 @@
# share no identity with each other or the platform, and nobody manages
# user accounts. State lives in /var/lib/carlos-app/<host> (chowned to
# the fresh uid each start); the app finds it via $STATE_DIRECTORY.
-# The IMDS firewall gate (harden-host) blocks dynamic uids by
-# construction — only root and carlos may mint host credentials.
+# IMDS is fenced twice: IPAddressDeny below blocks the metadata address
+# for every tenant at the unit level (a box with no nftables gate — tito,
+# issue #351 — is otherwise wide open), and on hardened boxes the
+# harden-host skuid gate blocks dynamic uids by construction — only
+# allowlisted uids may mint host credentials. A host_role tenant's
+# drop-in re-opens the unit-level fence with IPAddressAllow (allow wins
+# over deny); the skuid gate still has the last word.
#
# Caveat: DynamicUser is per-unit, so an app whose processes must share
# files across units (a router + instance family like titogo's) needs a
@@ -55,6 +60,7 @@ RestrictRealtime=yes
RestrictNamespaces=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
MemoryDenyWriteExecute=yes
MemoryMax=512M
TasksMax=256