fix: enforce HttpPutResponseHopLimit on the IMDSv2 token response (#1201)

This commit is contained in:
Julian Sommer
2026-10-15 09:52:10 +11:00
committed by GitHub
parent 6644e6dc9f
commit 29aef59dae
10 changed files with 460 additions and 10 deletions
+1 -1
View File
@@ -46,7 +46,6 @@ require (
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
@@ -197,6 +196,7 @@ require (
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/vuln v1.6.0 // indirect
+140
View File
@@ -0,0 +1,140 @@
package handlers_imds
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"syscall"
"golang.org/x/sys/unix"
)
// defaultPutHopLimit is the HttpPutResponseHopLimit the token response falls
// back to whenever the instance's own value cannot be established.
const defaultPutHopLimit = 1
// maxPutHopLimit is the AWS-valid upper bound for HttpPutResponseHopLimit.
const maxPutHopLimit = 64
// connTTLSetter sets the IP TTL of the connection a request arrived on.
type connTTLSetter func(ttl int) error
// connTTLFromConn returns a setter for c's socket, or nil if c exposes no socket.
func connTTLFromConn(c net.Conn) connTTLSetter {
sc, ok := c.(syscall.Conn)
if !ok {
return nil
}
return func(ttl int) error {
raw, err := sc.SyscallConn()
if err != nil {
return err
}
var sockErr error
if err := raw.Control(func(fd uintptr) {
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_TTL, ttl)
}); err != nil {
return err
}
return sockErr
}
}
// withConnTTL is the http.Server.ConnContext that lets handleToken reach the
// accepted socket. A conn without one gets no setter, so the PUT fails closed.
func withConnTTL(ctx context.Context, c net.Conn) context.Context {
if set := connTTLFromConn(c); set != nil {
return context.WithValue(ctx, ctxKeyConnTTL, set)
}
return ctx
}
var errNoConnTTL = errors.New("no TTL setter for request connection")
// setRequestConnTTL sets the IP TTL of the connection r's context came from.
func setRequestConnTTL(ctx context.Context, ttl int) error {
set, _ := ctx.Value(ctxKeyConnTTL).(connTTLSetter)
if set == nil {
return errNoConnTTL
}
return set(ttl)
}
// putResponseHopLimit resolves the instance's current HttpPutResponseHopLimit
// on every call, so ModifyInstanceMetadataOptions applies on the next PUT. A
// lookup failure, missing instance, or unset or out-of-range value yields 1.
func (s *IMDSServiceImpl) putResponseHopLimit(ctx context.Context, eni *eniFacts) int {
inst, err := s.resolver.resolveInstance(ctx, eni)
if err != nil {
slog.WarnContext(ctx, "IMDS: hop limit lookup failed, using default",
"instance_id", eni.instanceID, "hop_limit", defaultPutHopLimit, "err", err)
return defaultPutHopLimit
}
if inst == nil || inst.putHopLimit < 1 || inst.putHopLimit > maxPutHopLimit {
return defaultPutHopLimit
}
return int(inst.putHopLimit)
}
// limitListener caps concurrently open conns like netutil.LimitListener, but
// its conns still expose the accepted socket so the token PUT can set IP_TTL.
type limitListener struct {
net.Listener
sem chan struct{}
closeOnce sync.Once
done chan struct{}
}
func newLimitListener(l net.Listener, n int) *limitListener {
return &limitListener{Listener: l, sem: make(chan struct{}, n), done: make(chan struct{})}
}
// Accept blocks while n conns are open. It returns net.ErrClosed once the
// listener is closed, even while waiting for a slot.
func (l *limitListener) Accept() (net.Conn, error) {
select {
case l.sem <- struct{}{}:
case <-l.done:
return nil, net.ErrClosed
}
c, err := l.Listener.Accept()
if err != nil {
<-l.sem
return nil, err
}
return &limitConn{Conn: c, release: func() { <-l.sem }}, nil
}
func (l *limitListener) Close() error {
err := l.Listener.Close()
l.closeOnce.Do(func() { close(l.done) })
return err
}
// limitConn releases its listener slot on the first Close.
type limitConn struct {
net.Conn
releaseOnce sync.Once
release func()
}
var _ syscall.Conn = (*limitConn)(nil)
func (c *limitConn) Close() error {
err := c.Conn.Close()
c.releaseOnce.Do(c.release)
return err
}
// SyscallConn exposes the wrapped conn's socket.
func (c *limitConn) SyscallConn() (syscall.RawConn, error) {
sc, ok := c.Conn.(syscall.Conn)
if !ok {
return nil, errors.New("wrapped conn exposes no socket")
}
return sc.SyscallConn()
}
+279
View File
@@ -0,0 +1,279 @@
package handlers_imds
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// hopLimitResolver serves a fakeResolver whose instance hop limit can change
// between requests, standing in for ModifyInstanceMetadataOptions.
type hopLimitResolver struct {
fakeResolver
hopLimit atomic.Int64
}
func (h *hopLimitResolver) resolveInstance(_ context.Context, _ *eniFacts) (*instanceFacts, error) {
return &instanceFacts{putHopLimit: h.hopLimit.Load()}, nil
}
// putToken sends PUT /latest/api/token through h, recording the TTL the handler
// asked the connection for (0 if it asked for none).
func putToken(t *testing.T, res eniResolver) (*httptest.ResponseRecorder, int) {
t.Helper()
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
var ttl int
h := withTapConn(svc.httpHandler(), testENI(), func(v int) error { ttl = v; return nil })
req := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+pathToken, nil)
req.Header.Set(hdrTokenTTL, "60")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec, ttl
}
func TestHTTP_TokenPUTSetsHopLimitTTL(t *testing.T) {
cases := []struct {
name string
res *fakeResolver
want int
}{
{"default", &fakeResolver{eni: testENI(), inst: &instanceFacts{putHopLimit: 1}}, 1},
{"raised", &fakeResolver{eni: testENI(), inst: &instanceFacts{putHopLimit: 2}}, 2},
{"maximum", &fakeResolver{eni: testENI(), inst: &instanceFacts{putHopLimit: 64}}, 64},
{"unset fails closed", &fakeResolver{eni: testENI(), inst: &instanceFacts{}}, 1},
{"out of range fails closed", &fakeResolver{eni: testENI(), inst: &instanceFacts{putHopLimit: 65}}, 1},
{"instance missing fails closed", &fakeResolver{eni: testENI()}, 1},
{"lookup error fails closed", &fakeResolver{eni: testENI(), instErr: errors.New("gather timeout")}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec, ttl := putToken(t, tc.res)
require.Equal(t, http.StatusOK, rec.Code)
assert.NotEmpty(t, rec.Body.String())
assert.Equal(t, tc.want, ttl)
assert.Equal(t, "close", rec.Header().Get("Connection"),
"a keep-alive connection must not outlive the restricted TTL")
})
}
}
// ModifyInstanceMetadataOptions on a running instance applies to the next PUT.
func TestHTTP_TokenPUTFollowsModifiedHopLimit(t *testing.T) {
res := &hopLimitResolver{fakeResolver: fakeResolver{eni: testENI()}}
res.hopLimit.Store(1)
_, ttl := putToken(t, res)
assert.Equal(t, 1, ttl)
res.hopLimit.Store(2)
_, ttl = putToken(t, res)
assert.Equal(t, 2, ttl)
}
// Without a TTL in place, the token must not be sent at all.
func TestHTTP_TokenPUTFailsClosedWithoutTTL(t *testing.T) {
cases := []struct {
name string
setTTL connTTLSetter
}{
{"no setter", nil},
{"setter fails", func(int) error { return errors.New("setsockopt failed") }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := withTapConn(svc.httpHandler(), testENI(), tc.setTTL)
req := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+pathToken, nil)
req.Header.Set(hdrTokenTTL, "60")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Empty(t, rec.Body.String())
})
}
}
// ttlProbeListener records each accepted socket's IP_TTL as the server closes
// it, so the TTL the response actually left with is observable after the fact.
type ttlProbeListener struct {
net.Listener
closed chan int
}
func (l *ttlProbeListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
tcp, ok := c.(*net.TCPConn)
if !ok {
_ = c.Close()
return nil, errors.New("not a TCP conn")
}
return &ttlProbeConn{TCPConn: tcp, closed: l.closed}, nil
}
type ttlProbeConn struct {
*net.TCPConn
once sync.Once
closed chan int
}
func (c *ttlProbeConn) Close() error {
c.once.Do(func() { c.closed <- socketTTL(c.TCPConn) })
return c.TCPConn.Close()
}
func socketTTL(sc syscall.Conn) int {
raw, err := sc.SyscallConn()
if err != nil {
return -1
}
ttl := -1
_ = raw.Control(func(fd uintptr) {
if v, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_TTL); err == nil {
ttl = v
}
})
return ttl
}
func awaitTTL(t *testing.T, closed <-chan int) int {
t.Helper()
select {
case ttl := <-closed:
return ttl
case <-time.After(5 * time.Second):
t.Fatal("server never closed the connection")
return 0
}
}
// The token connection's server-side IP_TTL follows HttpPutResponseHopLimit
// through the production responder; metadata GETs keep the kernel default.
func TestTapResponder_TokenResponseTTLIsHopLimit(t *testing.T) {
const (
eniID = "eni-aaa"
endpoint = "ime-aaa"
)
res := &hopLimitResolver{fakeResolver: fakeResolver{eni: testENI()}}
res.hopLimit.Store(1)
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
closed := make(chan int, 4)
var addr string
listen := func(_ context.Context, _ string) (net.Listener, error) {
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, err
}
addr = ln.Addr().String()
return &ttlProbeListener{Listener: ln, closed: closed}, nil
}
m := newTapResponderManager(svc.httpHandler(), staticResolve(map[string]*eniFacts{eniID: testENI()}), listen)
require.NoError(t, m.start(context.Background(), eniID, endpoint))
t.Cleanup(func() { m.stop(eniID) })
token := putTapToken(t, addr)
assert.Equal(t, 1, awaitTTL(t, closed), "default hop limit must send the token with TTL 1")
res.hopLimit.Store(2)
putTapToken(t, addr)
assert.Equal(t, 2, awaitTTL(t, closed), "a modified hop limit must apply to the next PUT")
ref, err := net.ListenPacket("udp4", "127.0.0.1:0")
require.NoError(t, err)
defaultTTL := socketTTL(ref.(*net.UDPConn))
_ = ref.Close()
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
req, err := http.NewRequest(http.MethodGet, "http://"+addr+prefixMetaData+"instance-id", nil)
require.NoError(t, err)
req.Header.Set(hdrToken, token)
resp, err := client.Do(req)
require.NoError(t, err)
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, defaultTTL, awaitTTL(t, closed), "only the token response is hop-limited")
}
func TestLimitListener_CapsReleasesAndExposesSocket(t *testing.T) {
inner, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
l := newLimitListener(inner, 1)
defer func() { _ = l.Close() }()
dial := func() net.Conn {
c, err := net.Dial("tcp4", inner.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { _ = c.Close() })
return c
}
dial()
dial()
first, err := l.Accept()
require.NoError(t, err)
_, ok := first.(syscall.Conn)
require.True(t, ok, "accepted conns must expose the socket for IP_TTL")
require.NotNil(t, connTTLFromConn(first))
require.NoError(t, connTTLFromConn(first)(7))
assert.Equal(t, 7, socketTTL(first.(syscall.Conn)))
accepted := make(chan net.Conn, 1)
go func() {
c, err := l.Accept()
if err == nil {
accepted <- c
}
}()
select {
case <-accepted:
t.Fatal("Accept must block while the cap is reached")
case <-time.After(100 * time.Millisecond):
}
require.NoError(t, first.Close())
_ = first.Close() // a second Close must not release a second slot
select {
case c := <-accepted:
_ = c.Close()
case <-time.After(5 * time.Second):
t.Fatal("closing a conn must release its slot")
}
}
func TestLimitListener_CloseUnblocksAccept(t *testing.T) {
inner, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
l := newLimitListener(inner, 1)
l.sem <- struct{}{} // cap reached
done := make(chan error, 1)
go func() {
_, err := l.Accept()
done <- err
}()
require.NoError(t, l.Close())
select {
case err := <-done:
assert.ErrorIs(t, err, net.ErrClosed)
case <-time.After(5 * time.Second):
t.Fatal("Close must unblock an Accept waiting for a slot")
}
}
+1
View File
@@ -72,6 +72,7 @@ func TestIMDSv1_TokenedRequestSkipsLookup(t *testing.T) {
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
res.calls = 0 // the PUT resolves the hop limit; only the GET is under test
rec := get(t, h, prefixMetaData+"local-ipv4", token)
require.Equal(t, http.StatusOK, rec.Code)
assert.Zero(t, res.calls, "a valid token must not trigger an http-tokens lookup")
@@ -143,6 +143,7 @@ func instanceFactsFromVM(v *vm.VM) *instanceFacts {
}
if inst.MetadataOptions != nil {
facts.httpTokens = aws.StringValue(inst.MetadataOptions.HttpTokens)
facts.putHopLimit = aws.Int64Value(inst.MetadataOptions.HttpPutResponseHopLimit)
}
return facts
}
@@ -67,7 +67,7 @@ func testVM(id string) *vm.VM {
Architecture: aws.String("x86_64"),
AmiLaunchIndex: aws.Int64(0),
LaunchTime: aws.Time(launch),
MetadataOptions: &ec2.InstanceMetadataOptionsResponse{HttpTokens: aws.String("optional")},
MetadataOptions: &ec2.InstanceMetadataOptionsResponse{HttpTokens: aws.String("optional"), HttpPutResponseHopLimit: aws.Int64(3)},
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{DeviceName: aws.String("/dev/vda")},
},
@@ -91,6 +91,7 @@ func TestDescribe_LocalHit_ResolvesWithoutRecordSpace(t *testing.T) {
assert.Equal(t, "arn:aws:iam::111111111111:instance-profile/demo", facts.iamInstanceProfileArn)
assert.Equal(t, "spot", facts.lifecycleType)
assert.Equal(t, "optional", facts.httpTokens)
assert.Equal(t, int64(3), facts.putHopLimit)
assert.Equal(t, int64(0), facts.amiLaunchIndex)
assert.Equal(t, "r-abc123", facts.reservationID)
assert.Equal(t, []byte("hello"), facts.userData)
+21 -4
View File
@@ -99,6 +99,18 @@ func (s *IMDSServiceImpl) handleToken(w http.ResponseWriter, r *http.Request) {
return
}
// AWS sends the token with IP TTL equal to HttpPutResponseHopLimit, so a
// workload more routed hops away than that never receives it. No token is
// issued unless the TTL is in place.
hopLimit := s.putResponseHopLimit(r.Context(), eni)
if err := setRequestConnTTL(r.Context(), hopLimit); err != nil {
slog.ErrorContext(r.Context(), "IMDS: setting token response TTL failed", "eni_id", eni.eniID, "hop_limit", hopLimit, "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
// Closing the connection keeps the restricted TTL scoped to this response.
w.Header().Set("Connection", "close")
token, err := s.tokens.issue(eni.eniID, ttl, s.now())
if err != nil {
slog.ErrorContext(r.Context(), "IMDS: token issuance failed", "eni_id", eni.eniID, "err", err)
@@ -803,9 +815,14 @@ func writeJSON(w http.ResponseWriter, v any) {
}
// ctxKey is the unexported context-key type used to thread the per-tap ENI
// identity into each request.
// identity and the per-connection TTL setter into each request.
type ctxKey int
// ctxKeyENI carries a *eniFacts resolved once per per-tap responder — the
// authoritative caller identity for every request it serves.
const ctxKeyENI ctxKey = iota
const (
// ctxKeyENI carries a *eniFacts resolved once per per-tap responder — the
// authoritative caller identity for every request it serves.
ctxKeyENI ctxKey = iota
// ctxKeyConnTTL carries the connTTLSetter for the request's accepted socket,
// threaded per connection by the per-tap responder's ConnContext.
ctxKeyConnTTL
)
+12 -2
View File
@@ -120,11 +120,21 @@ func testENI() *eniFacts {
// request, the way the per-tap responder's BaseContext does in production. A nil
// eni leaves the context empty so resolveCaller misses and the request 404s.
func withTapENI(h http.Handler, eni *eniFacts) http.Handler {
return withTapConn(h, eni, func(int) error { return nil })
}
// withTapConn is withTapENI with an explicit connection TTL setter, standing in
// for the responder's ConnContext. A nil setter leaves it out of the context.
func withTapConn(h http.Handler, eni *eniFacts, setTTL connTTLSetter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if eni != nil {
r = r.WithContext(context.WithValue(r.Context(), ctxKeyENI, eni))
ctx = context.WithValue(ctx, ctxKeyENI, eni)
}
h.ServeHTTP(w, r)
if setTTL != nil {
ctx = context.WithValue(ctx, ctxKeyConnTTL, setTTL)
}
h.ServeHTTP(w, r.WithContext(ctx))
})
}
+1
View File
@@ -54,6 +54,7 @@ type instanceFacts struct {
reservationID string
lifecycleType string // "spot" for spot-launched instances, else empty (on-demand)
httpTokens string // "optional" permits IMDSv1; empty or "required" does not
putHopLimit int64 // HttpPutResponseHopLimit; 0 when unset
amiLaunchIndex int64
pendingTime time.Time
userData []byte
+2 -2
View File
@@ -11,7 +11,6 @@ import (
"syscall"
"time"
"golang.org/x/net/netutil"
"golang.org/x/sys/unix"
)
@@ -124,7 +123,7 @@ func (m *tapResponderManager) start(ctx context.Context, eniID, endpoint string)
return fmt.Errorf("listen on endpoint %s: %w", endpoint, err)
}
// Cap per-tap connections so one guest cannot exhaust vpcd's shared descriptors.
listener = netutil.LimitListener(listener, 32)
listener = newLimitListener(listener, 32)
ifindex, err := m.ifindex(endpoint)
if err != nil {
slog.DebugContext(ctx, "IMDS: endpoint ifindex unavailable; recreated-endpoint detection degraded", "endpoint", endpoint, "err", err)
@@ -140,6 +139,7 @@ func (m *tapResponderManager) start(ctx context.Context, eniID, endpoint string)
BaseContext: func(net.Listener) context.Context {
return context.WithValue(ctx, ctxKeyENI, eni)
},
ConnContext: withConnTTL,
}
m.mu.Lock()