Made security review, applied recommendations.

This commit is contained in:
slawek committed 2026-10-20 09:48:03 +02:00
1 parent 630375c7ed
commit 1d4ca3da46
7 files changed
+363 -70

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
*
!go.mod
!go.sum
!main.go
!main_test.go
+5 -4
View File
@@ -1,13 +1,14 @@
FROM golang:1.27-trixie AS build
FROM golang:1.27.3-trixie AS build
ENV GOTOOLCHAIN=local CGO_ENABLED=0
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY main.go main_test.go ./
RUN CGO_ENABLED=0 go test ./... \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /imds-emulator .
RUN go test ./... \
&& go build -trimpath -ldflags="-s -w" -o /imds-emulator .
FROM gcr.io/distroless/static-debian13
FROM gcr.io/distroless/static-debian13:latest
COPY --from=build /imds-emulator /imds-emulator
+12 -1
View File
@@ -2,6 +2,8 @@ name: sts
services:
imds-emulator:
build: .
restart: unless-stopped
stop_grace_period: 30s
volumes:
- /etc/sts-agent:/etc/sts-agent:ro
environment:
@@ -9,11 +11,20 @@ services:
GOOGLE_API_CERTIFICATE_CONFIG: /etc/sts-agent/certificate_config.json
PROJECT_ID: ${PROJECT_ID}
PROJECT_NUMBER: ${PROJECT_NUMBER}
healthcheck:
test: ["CMD", "/imds-emulator", "-check"]
interval: 10s
timeout: 3s
start_period: 10s
start_interval: 1s
retries: 3
agent:
image: gcr.io/cloud-ingest/tsop-agent:latest
restart: unless-stopped
depends_on:
- imds-emulator
imds-emulator:
condition: service_healthy
ulimits:
memlock: 64000000
volumes:
+1 -1
View File
@@ -1,6 +1,6 @@
module imds-emulator
go 1.27.1
go 1.27.3
require (
cloud.google.com/go/auth v0.24.1
+59 -10
View File
@@ -6,6 +6,8 @@ import (
"cmp"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
@@ -31,8 +33,11 @@ const (
accounts = "instance/service-accounts/"
defaultAccount = accounts + "default/"
// shutdownGrace is shorter than the 10 seconds Docker waits after SIGTERM.
shutdownGrace = 5 * time.Second
// shutdownGrace is shorter than the stop grace period of the container.
shutdownGrace = 20 * time.Second
// maxProviders bounds the token providers kept for distinct scope sets.
maxProviders = 16
)
// tokenFunc returns an access token for the scopes.
@@ -96,11 +101,7 @@ func (s *metadataServer) handle(w http.ResponseWriter, r *http.Request) int {
switch {
case path == defaultAccount+"token":
scopes := []string{defaultScope}
if value := query.Get("scopes"); value != "" {
scopes = strings.Split(value, ",")
}
token, err := s.token(r.Context(), scopes)
token, err := s.token(r.Context(), parseScopes(query.Get("scopes")))
if err != nil {
log.Printf("token error: %v", err)
return text(w, http.StatusInternalServerError, "Internal Server Error")
@@ -138,11 +139,22 @@ func (s *metadataServer) handle(w http.ResponseWriter, r *http.Request) int {
return text(w, http.StatusNotFound, "Not Found")
}
// parseScopes returns the comma separated scopes of the query, or the default scope.
func parseScopes(value string) []string {
scopes := slices.DeleteFunc(strings.Split(value, ","), func(scope string) bool { return scope == "" })
if len(scopes) == 0 {
return []string{defaultScope}
}
return scopes
}
// text writes a plain text response and returns its status.
func text(w http.ResponseWriter, status int, body string) int {
w.Header().Set("Content-Type", "application/text")
w.WriteHeader(status)
io.WriteString(w, body)
if _, err := io.WriteString(w, body); err != nil {
log.Printf("write error: %v", err)
}
return status
}
@@ -150,10 +162,31 @@ func text(w http.ResponseWriter, status int, body string) int {
func jsonBody(w http.ResponseWriter, value any) int {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(value)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("write error: %v", err)
}
return http.StatusOK
}
// ping checks that the emulator at the address answers the metadata ping.
func ping(address string) error {
request, err := http.NewRequest(http.MethodGet, "http://"+address+"/", nil)
if err != nil {
return err
}
request.Header.Set("Metadata-Flavor", "Google")
client := &http.Client{Timeout: 2 * time.Second}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || response.Header.Get("Metadata-Flavor") != "Google" {
return fmt.Errorf("unexpected response: %s", response.Status)
}
return nil
}
var impersonationURL = regexp.MustCompile(`/serviceAccounts/([^/:]+):generateAccessToken$`)
// serviceAccountEmail returns the service account of a credentials file, or an empty string.
@@ -193,6 +226,10 @@ func (c *providerCache) get(scopes []string) (auth.TokenProvider, error) {
if c.providers == nil {
c.providers = map[string]auth.TokenProvider{}
}
// Drop all providers once the limit is reached, so the cache never grows unbounded.
if len(c.providers) >= maxProviders {
clear(c.providers)
}
c.providers[key] = provider
return provider, nil
}
@@ -230,6 +267,18 @@ func serve(ctx context.Context, server *http.Server, listener net.Listener, grac
}
func main() {
check := flag.Bool("check", false, "check that the emulator answers and exit")
flag.Parse()
// IMDS_HOST empty binds all interfaces; the check then uses the loopback address.
host, port := os.Getenv("IMDS_HOST"), cmp.Or(os.Getenv("IMDS_PORT"), "8080")
if *check {
if err := ping(net.JoinHostPort(cmp.Or(host, "127.0.0.1"), port)); err != nil {
log.Fatalf("Check failed: %v", err)
}
return
}
// The first signal stops the server gracefully, the second one exits at once.
signals := make(chan os.Signal, 2)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
@@ -279,7 +328,7 @@ func main() {
IdleTimeout: 60 * time.Second,
}
listener, err := net.Listen("tcp", ":"+cmp.Or(os.Getenv("IMDS_PORT"), "8080"))
listener, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err != nil {
log.Fatalf("Cannot listen: %v", err)
}
+262 -49
View File
@@ -205,10 +205,80 @@ func TestToken(t *testing.T) {
if !slices.Equal(scopes, test.wantScopes) {
t.Errorf("scopes = %v, want %v", scopes, test.wantScopes)
}
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
})
}
}
func TestParseScopes(t *testing.T) {
tests := []struct {
value string
want []string
}{
{"", []string{defaultScope}},
{",,", []string{defaultScope}},
{"a", []string{"a"}},
{"a,,b,", []string{"a", "b"}},
}
for _, test := range tests {
if got := parseScopes(test.value); !slices.Equal(got, test.want) {
t.Errorf("parseScopes(%q) = %v, want %v", test.value, got, test.want)
}
}
}
func TestRequestLogOmitsQuery(t *testing.T) {
var logged bytes.Buffer
log.SetOutput(&logged)
defer log.SetOutput(io.Discard)
get(newTestServer(nil), root+"instance/service-accounts/default/token?scopes=secret-scope")
if !strings.Contains(logged.String(), "GET "+root+"instance/service-accounts/default/token 200") {
t.Errorf("the request was not logged:\n%s", logged.String())
}
if strings.Contains(logged.String(), "secret-scope") {
t.Errorf("the log contains the query:\n%s", logged.String())
}
}
func TestHeadHasNoBody(t *testing.T) {
server := httptest.NewServer(newTestServer(nil))
defer server.Close()
request, _ := http.NewRequest(http.MethodHead, server.URL+root+"project/project-id", nil)
request.Header.Set("Metadata-Flavor", "Google")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != 200 || len(body) != 0 {
t.Errorf("got %d with body %q, want 200 without body", response.StatusCode, body)
}
}
func TestPing(t *testing.T) {
emulator := httptest.NewServer(newTestServer(nil))
defer emulator.Close()
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer other.Close()
closed := httptest.NewServer(nil)
closed.Close()
if err := ping(strings.TrimPrefix(emulator.URL, "http://")); err != nil {
t.Errorf("ping(emulator) = %v, want nil", err)
}
if err := ping(strings.TrimPrefix(other.URL, "http://")); err == nil {
t.Error("ping(other server) = nil, want an error")
}
if err := ping(strings.TrimPrefix(closed.URL, "http://")); err == nil {
t.Error("ping(closed) = nil, want an error")
}
}
func TestTokenExpiry(t *testing.T) {
tests := []struct {
name string
@@ -315,6 +385,20 @@ func TestProviderCache(t *testing.T) {
}
}
func TestProviderCacheBound(t *testing.T) {
cache := &providerCache{detect: func([]string) (auth.TokenProvider, error) {
return tokenProviderFunc(func(context.Context) (*auth.Token, error) { return &auth.Token{Value: "t"}, nil }), nil
}}
for i := range 10 * maxProviders {
if _, err := cache.token(context.Background(), []string{strings.Repeat("s", i+1)}); err != nil {
t.Fatal(err)
}
if len(cache.providers) > maxProviders {
t.Fatalf("%d providers cached after %d scope sets, want at most %d", len(cache.providers), i+1, maxProviders)
}
}
}
func TestProviderCacheConcurrent(t *testing.T) {
var detected atomic.Int32
cache := &providerCache{detect: func([]string) (auth.TokenProvider, error) {
@@ -497,6 +581,98 @@ func writeServiceAccountKey(t *testing.T) string {
return path
}
// safeBuffer collects the output of a process while it runs.
type safeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *safeBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *safeBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// emulator is the test binary running as the emulator process.
type emulator struct {
address string
process *os.Process
exited <-chan error
output *safeBuffer
}
// freePort returns a free TCP port of the loopback interface.
func freePort(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
_, port, _ := net.SplitHostPort(listener.Addr().String())
return port
}
// runEmulator starts the test binary as the emulator with the arguments and
// the environment on the loopback interface, and returns it once it answers.
func runEmulator(t *testing.T, env []string, args ...string) *emulator {
t.Helper()
port := freePort(t)
e := &emulator{address: "127.0.0.1:" + port, output: &safeBuffer{}}
command := exec.Command(os.Args[0], args...)
command.Env = append(os.Environ(), mainEnv+"=1", "GOOGLE_APPLICATION_CREDENTIALS="+writeServiceAccountKey(t), "IMDS_HOST=127.0.0.1", "IMDS_PORT="+port)
command.Env = append(command.Env, env...)
command.Stderr = e.output
if err := command.Start(); err != nil {
t.Fatal(err)
}
exited := make(chan error, 1)
go func() { exited <- command.Wait() }()
e.process, e.exited = command.Process, exited
t.Cleanup(func() { command.Process.Kill() })
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); {
select {
case err := <-exited:
t.Fatalf("the emulator exited before it was ready: %v\n%s", err, e.output.String())
default:
}
if ping(e.address) == nil {
return e
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("the emulator did not answer\n%s", e.output.String())
return nil
}
// wait returns the exit error of the emulator, failing the test when it does not stop.
func (e *emulator) wait(t *testing.T) error {
t.Helper()
select {
case err := <-e.exited:
return err
case <-time.After(10 * time.Second):
t.Fatalf("the emulator did not stop\n%s", e.output.String())
return nil
}
}
// runCheck runs the test binary in check mode against the port and returns its exit error.
func runCheck(t *testing.T, port string) error {
t.Helper()
command := exec.Command(os.Args[0], "-check")
command.Env = append(os.Environ(), mainEnv+"=1", "IMDS_PORT="+port)
return command.Run()
}
// TestSignals runs the emulator as a process and stops it with signals.
func TestSignals(t *testing.T) {
if runtime.GOOS == "windows" {
@@ -504,59 +680,96 @@ func TestSignals(t *testing.T) {
}
for _, signal := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT} {
t.Run(signal.String(), func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
e := runEmulator(t, nil)
if err := e.process.Signal(signal); err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
_, port, _ := net.SplitHostPort(address)
listener.Close()
var output bytes.Buffer
command := exec.Command(os.Args[0])
command.Env = append(os.Environ(), mainEnv+"=1", "GOOGLE_APPLICATION_CREDENTIALS="+writeServiceAccountKey(t), "IMDS_PORT="+port)
command.Stderr = &output
if err := command.Start(); err != nil {
t.Fatal(err)
if err := e.wait(t); err != nil {
t.Errorf("the emulator exited with %v, want status 0\n%s", err, e.output.String())
}
exited := make(chan error, 1)
go func() { exited <- command.Wait() }()
t.Cleanup(func() { command.Process.Kill() })
// Wait until the emulator answers the ping.
ready := false
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !ready; {
select {
case err := <-exited:
t.Fatalf("the emulator exited before it was ready: %v\n%s", err, output.String())
default:
}
request, _ := http.NewRequest(http.MethodGet, "http://"+address+"/", nil)
request.Header.Set("Metadata-Flavor", "Google")
if response, err := http.DefaultClient.Do(request); err == nil {
response.Body.Close()
ready = response.StatusCode == http.StatusOK
}
time.Sleep(50 * time.Millisecond)
}
if !ready {
t.Fatalf("the emulator did not answer\n%s", output.String())
}
if err := command.Process.Signal(signal); err != nil {
t.Fatal(err)
}
select {
case err := <-exited:
if err != nil {
t.Errorf("the emulator exited with %v, want status 0\n%s", err, output.String())
}
case <-time.After(10 * time.Second):
t.Fatalf("the emulator did not stop\n%s", output.String())
}
if !strings.Contains(output.String(), "shutting down") || !strings.Contains(output.String(), "Stopped") {
t.Errorf("unexpected log:\n%s", output.String())
if !strings.Contains(e.output.String(), "shutting down") || !strings.Contains(e.output.String(), "Stopped") {
t.Errorf("unexpected log:\n%s", e.output.String())
}
})
}
t.Run("second signal", func(t *testing.T) {
e := runEmulator(t, nil)
// A partial request keeps a connection active, so the shutdown waits for it.
connection, err := net.Dial("tcp", e.address)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
if _, err := io.WriteString(connection, "GET / HTTP/1.1\r\nHost: imds\r\n"); err != nil {
t.Fatal(err)
}
if err := e.process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
select {
case err := <-e.exited:
t.Fatalf("the emulator exited with %v while a connection was active", err)
case <-time.After(500 * time.Millisecond):
}
if err := e.process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
var exitError *exec.ExitError
if err := e.wait(t); !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Errorf("the emulator exited with %v, want status 1\n%s", err, e.output.String())
}
if !strings.Contains(e.output.String(), "again, exiting") {
t.Errorf("unexpected log:\n%s", e.output.String())
}
})
}
// TestProcess checks the environment handling and the check mode of the emulator process.
func TestProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
email := func(t *testing.T, address string) string {
t.Helper()
request, _ := http.NewRequest(http.MethodGet, "http://"+address+root+"instance/service-accounts/default/email", nil)
request.Header.Set("Metadata-Flavor", "Google")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
return string(body)
}
t.Run("email from credentials", func(t *testing.T) {
e := runEmulator(t, nil)
if got := email(t, e.address); got != testEmail {
t.Errorf("email = %q, want %q", got, testEmail)
}
if !strings.Contains(e.output.String(), "Serving tokens of "+testEmail+" for project dom-lab on "+e.address) {
t.Errorf("unexpected log:\n%s", e.output.String())
}
})
t.Run("email override", func(t *testing.T) {
e := runEmulator(t, []string{"SERVICE_ACCOUNT_EMAIL=other@dom-lab.iam.gserviceaccount.com"})
if got := email(t, e.address); got != "other@dom-lab.iam.gserviceaccount.com" {
t.Errorf("email = %q, want the override", got)
}
})
t.Run("check", func(t *testing.T) {
e := runEmulator(t, nil)
_, port, _ := net.SplitHostPort(e.address)
if err := runCheck(t, port); err != nil {
t.Errorf("check against the emulator = %v, want nil", err)
}
if err := runCheck(t, freePort(t)); err == nil {
t.Error("check against a closed port = nil, want an error")
}
})
}