headscale/integration/api_auth_test.go
Kristoffer Dalby b36438bf90 all: disable thelper linter and clean up nolint comments
Disable the thelper linter which triggers on inline test closures in
table-driven tests. These closures are intentionally not standalone
helpers and don't benefit from t.Helper().

Also remove explanatory comments from nolint directives throughout the
codebase as they add noise without providing significant value.
2026-01-21 15:56:57 +00:00

697 lines
20 KiB
Go

package integration
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
)
// TestAPIAuthenticationBypass tests that the API authentication middleware
// properly blocks unauthorized requests and does not leak sensitive data.
// This test reproduces the security issue described in:
// - https://github.com/juanfont/headscale/issues/2809
// - https://github.com/juanfont/headscale/pull/2810
//
// The bug: When authentication fails, the middleware writes "Unauthorized"
// but doesn't return early, allowing the handler to execute and append
// sensitive data to the response.
func TestAPIAuthenticationBypass(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
Users: []string{"user1", "user2", "user3"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("apiauthbypass"))
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// Create an API key using the CLI
var validAPIKey string
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
apiKeyOutput, err := headscale.Execute(
[]string{
"headscale",
"apikeys",
"create",
"--expiration",
"24h",
},
)
assert.NoError(ct, err)
assert.NotEmpty(ct, apiKeyOutput)
validAPIKey = strings.TrimSpace(apiKeyOutput)
}, 20*time.Second, 1*time.Second)
// Get the API endpoint
endpoint := headscale.GetEndpoint()
apiURL := endpoint + "/api/v1/user"
// Create HTTP client
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
},
}
t.Run("HTTP_NoAuthHeader", func(t *testing.T) {
// Test 1: Request without any Authorization header
// Expected: Should return 401 with ONLY "Unauthorized" text, no user data
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
"Expected 401 status code for request without auth header")
bodyStr := string(body)
// Should contain "Unauthorized" message
assert.Contains(t, bodyStr, "Unauthorized",
"Response should contain 'Unauthorized' message")
// Should NOT contain user data after "Unauthorized"
// This is the security bypass - if users array is present, auth was bypassed
var jsonCheck map[string]any
jsonErr := json.Unmarshal(body, &jsonCheck)
// If we can unmarshal JSON and it contains "users", that's the bypass
if jsonErr == nil {
assert.NotContains(t, jsonCheck, "users",
"SECURITY ISSUE: Response should NOT contain 'users' data when unauthorized")
assert.NotContains(t, jsonCheck, "user",
"SECURITY ISSUE: Response should NOT contain 'user' data when unauthorized")
}
// Additional check: response should not contain "user1", "user2", "user3"
assert.NotContains(t, bodyStr, "user1",
"SECURITY ISSUE: Response should NOT leak user 'user1' data")
assert.NotContains(t, bodyStr, "user2",
"SECURITY ISSUE: Response should NOT leak user 'user2' data")
assert.NotContains(t, bodyStr, "user3",
"SECURITY ISSUE: Response should NOT leak user 'user3' data")
// Response should be minimal, just "Unauthorized"
// Allow some variation in response format but body should be small
assert.Less(t, len(bodyStr), 100,
"SECURITY ISSUE: Unauthorized response body should be minimal, got: %s", bodyStr)
})
t.Run("HTTP_InvalidAuthHeader", func(t *testing.T) {
// Test 2: Request with invalid Authorization header (missing "Bearer " prefix)
// Expected: Should return 401 with ONLY "Unauthorized" text, no user data
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "InvalidToken")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
"Expected 401 status code for invalid auth header format")
bodyStr := string(body)
assert.Contains(t, bodyStr, "Unauthorized")
// Should not leak user data
assert.NotContains(t, bodyStr, "user1",
"SECURITY ISSUE: Response should NOT leak user data")
assert.NotContains(t, bodyStr, "user2",
"SECURITY ISSUE: Response should NOT leak user data")
assert.NotContains(t, bodyStr, "user3",
"SECURITY ISSUE: Response should NOT leak user data")
assert.Less(t, len(bodyStr), 100,
"SECURITY ISSUE: Unauthorized response should be minimal")
})
t.Run("HTTP_InvalidBearerToken", func(t *testing.T) {
// Test 3: Request with Bearer prefix but invalid token
// Expected: Should return 401 with ONLY "Unauthorized" text, no user data
// Note: Both malformed and properly formatted invalid tokens should return 401
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer invalid-token-12345")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
"Expected 401 status code for invalid bearer token")
bodyStr := string(body)
assert.Contains(t, bodyStr, "Unauthorized")
// Should not leak user data
assert.NotContains(t, bodyStr, "user1",
"SECURITY ISSUE: Response should NOT leak user data")
assert.NotContains(t, bodyStr, "user2",
"SECURITY ISSUE: Response should NOT leak user data")
assert.NotContains(t, bodyStr, "user3",
"SECURITY ISSUE: Response should NOT leak user data")
assert.Less(t, len(bodyStr), 100,
"SECURITY ISSUE: Unauthorized response should be minimal")
})
t.Run("HTTP_ValidAPIKey", func(t *testing.T) {
// Test 4: Request with valid API key
// Expected: Should return 200 with user data (this is the authorized case)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+validAPIKey)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// Should succeed with valid auth
assert.Equal(t, http.StatusOK, resp.StatusCode,
"Expected 200 status code with valid API key")
// Should be able to parse as protobuf JSON
var response v1.ListUsersResponse
err = protojson.Unmarshal(body, &response)
require.NoError(t, err, "Response should be valid protobuf JSON with valid API key")
// Should contain our test users
users := response.GetUsers()
assert.Len(t, users, 3, "Should have 3 users")
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.GetName()
}
assert.Contains(t, userNames, "user1")
assert.Contains(t, userNames, "user2")
assert.Contains(t, userNames, "user3")
})
}
// TestAPIAuthenticationBypassCurl tests the same security issue using curl
// from inside a container, which is closer to how the issue was discovered.
func TestAPIAuthenticationBypassCurl(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
Users: []string{"testuser1", "testuser2"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("apiauthcurl"))
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// Create a valid API key
apiKeyOutput, err := headscale.Execute(
[]string{
"headscale",
"apikeys",
"create",
"--expiration",
"24h",
},
)
require.NoError(t, err)
validAPIKey := strings.TrimSpace(apiKeyOutput)
endpoint := headscale.GetEndpoint()
apiURL := endpoint + "/api/v1/user"
t.Run("Curl_NoAuth", func(t *testing.T) {
// Execute curl from inside the headscale container without auth
curlOutput, err := headscale.Execute(
[]string{
"curl",
"-s",
"-w",
"\nHTTP_CODE:%{http_code}",
apiURL,
},
)
require.NoError(t, err)
// Parse the output
lines := strings.Split(curlOutput, "\n")
var (
httpCode string
responseBody string
)
var responseBodySb295 strings.Builder
for _, line := range lines {
if after, ok := strings.CutPrefix(line, "HTTP_CODE:"); ok {
httpCode = after
} else {
responseBodySb295.WriteString(line)
}
}
responseBody += responseBodySb295.String()
// Should return 401
assert.Equal(t, "401", httpCode,
"Curl without auth should return 401")
// Should contain Unauthorized
assert.Contains(t, responseBody, "Unauthorized",
"Response should contain 'Unauthorized'")
// Should NOT leak user data
assert.NotContains(t, responseBody, "testuser1",
"SECURITY ISSUE: Should not leak user data")
assert.NotContains(t, responseBody, "testuser2",
"SECURITY ISSUE: Should not leak user data")
// Response should be small (just "Unauthorized")
assert.Less(t, len(responseBody), 100,
"SECURITY ISSUE: Unauthorized response should be minimal, got: %s", responseBody)
})
t.Run("Curl_InvalidAuth", func(t *testing.T) {
// Execute curl with invalid auth header
curlOutput, err := headscale.Execute(
[]string{
"curl",
"-s",
"-H",
"Authorization: InvalidToken",
"-w",
"\nHTTP_CODE:%{http_code}",
apiURL,
},
)
require.NoError(t, err)
lines := strings.Split(curlOutput, "\n")
var (
httpCode string
responseBody string
)
var responseBodySb344 strings.Builder
for _, line := range lines {
if after, ok := strings.CutPrefix(line, "HTTP_CODE:"); ok {
httpCode = after
} else {
responseBodySb344.WriteString(line)
}
}
responseBody += responseBodySb344.String()
assert.Equal(t, "401", httpCode)
assert.Contains(t, responseBody, "Unauthorized")
assert.NotContains(t, responseBody, "testuser1",
"SECURITY ISSUE: Should not leak user data")
assert.NotContains(t, responseBody, "testuser2",
"SECURITY ISSUE: Should not leak user data")
})
t.Run("Curl_ValidAuth", func(t *testing.T) {
// Execute curl with valid API key
curlOutput, err := headscale.Execute(
[]string{
"curl",
"-s",
"-H",
"Authorization: Bearer " + validAPIKey,
"-w",
"\nHTTP_CODE:%{http_code}",
apiURL,
},
)
require.NoError(t, err)
lines := strings.Split(curlOutput, "\n")
var (
httpCode string
responseBody strings.Builder
)
for _, line := range lines {
if after, ok := strings.CutPrefix(line, "HTTP_CODE:"); ok {
httpCode = after
} else {
responseBody.WriteString(line)
}
}
// Should succeed
assert.Equal(t, "200", httpCode,
"Curl with valid API key should return 200")
// Should contain user data
var response v1.ListUsersResponse
err = protojson.Unmarshal([]byte(responseBody.String()), &response)
require.NoError(t, err, "Response should be valid protobuf JSON")
users := response.GetUsers()
assert.Len(t, users, 2, "Should have 2 users")
})
}
// TestGRPCAuthenticationBypass tests that the gRPC authentication interceptor
// properly blocks unauthorized requests.
// This test verifies that the gRPC API does not have the same bypass issue
// as the HTTP API middleware.
func TestGRPCAuthenticationBypass(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
Users: []string{"grpcuser1", "grpcuser2"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
// We need TLS for remote gRPC connections
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("grpcauthtest"),
hsic.WithTLS(),
hsic.WithConfigEnv(map[string]string{
// Enable gRPC on the standard port
"HEADSCALE_GRPC_LISTEN_ADDR": "0.0.0.0:50443",
}),
)
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// Create a valid API key
apiKeyOutput, err := headscale.Execute(
[]string{
"headscale",
"apikeys",
"create",
"--expiration",
"24h",
},
)
require.NoError(t, err)
validAPIKey := strings.TrimSpace(apiKeyOutput)
// Get the gRPC endpoint
// For gRPC, we need to use the hostname and port 50443
grpcAddress := headscale.GetHostname() + ":50443"
t.Run("gRPC_NoAPIKey", func(t *testing.T) {
// Test 1: Try to use CLI without API key (should fail)
// When HEADSCALE_CLI_ADDRESS is set but HEADSCALE_CLI_API_KEY is not set,
// the CLI should fail immediately
_, err := headscale.Execute(
[]string{
"sh", "-c",
fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", grpcAddress),
},
)
// Should fail - CLI exits when API key is missing
assert.Error(t, err,
"gRPC connection without API key should fail")
})
t.Run("gRPC_InvalidAPIKey", func(t *testing.T) {
// Test 2: Try to use CLI with invalid API key (should fail with auth error)
output, err := headscale.Execute(
[]string{
"sh", "-c",
fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=invalid-key-12345 HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", grpcAddress),
},
)
// Should fail with authentication error
require.Error(t, err,
"gRPC connection with invalid API key should fail")
// Should contain authentication error message
outputStr := strings.ToLower(output)
assert.True(t,
strings.Contains(outputStr, "unauthenticated") ||
strings.Contains(outputStr, "invalid token") ||
strings.Contains(outputStr, "failed to validate token") ||
strings.Contains(outputStr, "authentication"),
"Error should indicate authentication failure, got: %s", output)
// Should NOT leak user data
assert.NotContains(t, output, "grpcuser1",
"SECURITY ISSUE: gRPC should not leak user data with invalid auth")
assert.NotContains(t, output, "grpcuser2",
"SECURITY ISSUE: gRPC should not leak user data with invalid auth")
})
t.Run("gRPC_ValidAPIKey", func(t *testing.T) {
// Test 3: Use CLI with valid API key (should succeed)
output, err := headscale.Execute(
[]string{
"sh", "-c",
fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json", grpcAddress, validAPIKey),
},
)
// Should succeed
require.NoError(t, err,
"gRPC connection with valid API key should succeed, output: %s", output)
// CLI outputs the users array directly, not wrapped in ListUsersResponse
// Parse as JSON array (CLI uses json.Marshal, not protojson)
var users []*v1.User
err = json.Unmarshal([]byte(output), &users)
require.NoError(t, err, "Response should be valid JSON array")
assert.Len(t, users, 2, "Should have 2 users")
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.GetName()
}
assert.Contains(t, userNames, "grpcuser1")
assert.Contains(t, userNames, "grpcuser2")
})
}
// TestCLIWithConfigAuthenticationBypass tests that the headscale CLI
// with --config flag does not have authentication bypass issues when
// connecting to a remote server.
// Note: When using --config with local unix socket, no auth is needed.
// This test focuses on remote gRPC connections which require API keys.
func TestCLIWithConfigAuthenticationBypass(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
Users: []string{"cliuser1", "cliuser2"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("cliconfigauth"),
hsic.WithTLS(),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_GRPC_LISTEN_ADDR": "0.0.0.0:50443",
}),
)
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// Create a valid API key
apiKeyOutput, err := headscale.Execute(
[]string{
"headscale",
"apikeys",
"create",
"--expiration",
"24h",
},
)
require.NoError(t, err)
validAPIKey := strings.TrimSpace(apiKeyOutput)
grpcAddress := headscale.GetHostname() + ":50443"
// Create a config file for testing
configWithoutKey := fmt.Sprintf(`
cli:
address: %s
timeout: 5s
insecure: true
`, grpcAddress)
configWithInvalidKey := fmt.Sprintf(`
cli:
address: %s
api_key: invalid-key-12345
timeout: 5s
insecure: true
`, grpcAddress)
configWithValidKey := fmt.Sprintf(`
cli:
address: %s
api_key: %s
timeout: 5s
insecure: true
`, grpcAddress, validAPIKey)
t.Run("CLI_Config_NoAPIKey", func(t *testing.T) {
// Create config file without API key
err := headscale.WriteFile("/tmp/config_no_key.yaml", []byte(configWithoutKey))
require.NoError(t, err)
// Try to use CLI with config that has no API key
_, err = headscale.Execute(
[]string{
"headscale",
"--config", "/tmp/config_no_key.yaml",
"users", "list",
"--output", "json",
},
)
// Should fail
assert.Error(t, err,
"CLI with config missing API key should fail")
})
t.Run("CLI_Config_InvalidAPIKey", func(t *testing.T) {
// Create config file with invalid API key
err := headscale.WriteFile("/tmp/config_invalid_key.yaml", []byte(configWithInvalidKey))
require.NoError(t, err)
// Try to use CLI with invalid API key
output, err := headscale.Execute(
[]string{
"sh", "-c",
"headscale --config /tmp/config_invalid_key.yaml users list --output json 2>&1",
},
)
// Should fail
require.Error(t, err,
"CLI with invalid API key should fail")
// Should indicate authentication failure
outputStr := strings.ToLower(output)
assert.True(t,
strings.Contains(outputStr, "unauthenticated") ||
strings.Contains(outputStr, "invalid token") ||
strings.Contains(outputStr, "failed to validate token") ||
strings.Contains(outputStr, "authentication"),
"Error should indicate authentication failure, got: %s", output)
// Should NOT leak user data
assert.NotContains(t, output, "cliuser1",
"SECURITY ISSUE: CLI should not leak user data with invalid auth")
assert.NotContains(t, output, "cliuser2",
"SECURITY ISSUE: CLI should not leak user data with invalid auth")
})
t.Run("CLI_Config_ValidAPIKey", func(t *testing.T) {
// Create config file with valid API key
err := headscale.WriteFile("/tmp/config_valid_key.yaml", []byte(configWithValidKey))
require.NoError(t, err)
// Use CLI with valid API key
output, err := headscale.Execute(
[]string{
"headscale",
"--config", "/tmp/config_valid_key.yaml",
"users", "list",
"--output", "json",
},
)
// Should succeed
require.NoError(t, err,
"CLI with valid API key should succeed")
// CLI outputs the users array directly, not wrapped in ListUsersResponse
// Parse as JSON array (CLI uses json.Marshal, not protojson)
var users []*v1.User
err = json.Unmarshal([]byte(output), &users)
require.NoError(t, err, "Response should be valid JSON array")
assert.Len(t, users, 2, "Should have 2 users")
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.GetName()
}
assert.Contains(t, userNames, "cliuser1")
assert.Contains(t, userNames, "cliuser2")
})
}