Skip to content

Commit a2eef3c

Browse files
authored
Merge pull request #17 from bbrowning/defensive-checks-no-crash
Add defensive nil checks to prevent container crashes from malformed requests
2 parents e990bd7 + 6c0a863 commit a2eef3c

9 files changed

Lines changed: 348 additions & 1 deletion

File tree

internal/credentials/gcloud.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ func (g *GCloudInjector) init() error {
7373
// Inject sets the Authorization: Bearer header with a fresh OAuth2 token.
7474
// Always overrides — the agent may have a token from a dummy ADC file.
7575
func (g *GCloudInjector) Inject(req *http.Request) bool {
76+
if req == nil {
77+
log.Printf("DEFENSIVE_CHECK: GCloudInjector.Inject called with nil request")
78+
return false
79+
}
80+
7681
if err := g.init(); err != nil {
7782
log.Printf("ERROR gcloud credential init failed: %v", err)
7883
return false
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package credentials
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
)
7+
8+
func TestGCloudInjector_NilRequest(t *testing.T) {
9+
// Create injector with non-existent path (will fail init, but that's OK for this test)
10+
inj := NewGCloudInjector("/nonexistent/path/to/adc.json")
11+
12+
// Should handle nil request gracefully
13+
if inj.Inject(nil) {
14+
t.Error("nil request should return false")
15+
}
16+
}
17+
18+
func TestGCloudInjector_NilHeader(t *testing.T) {
19+
inj := NewGCloudInjector("/nonexistent/path/to/adc.json")
20+
21+
req := &http.Request{
22+
Header: nil,
23+
}
24+
25+
if inj.Inject(req) {
26+
t.Error("request with nil Header should return false")
27+
}
28+
}
29+
30+
func TestGCloudInjector_InitFailure(t *testing.T) {
31+
inj := NewGCloudInjector("/nonexistent/path/to/adc.json")
32+
33+
req := &http.Request{
34+
Header: make(http.Header),
35+
}
36+
37+
// Should return false due to init failure (file doesn't exist)
38+
if inj.Inject(req) {
39+
t.Error("inject should fail when ADC file doesn't exist")
40+
}
41+
42+
// Authorization header should not be set
43+
if got := req.Header.Get("Authorization"); got != "" {
44+
t.Errorf("Authorization should be empty on init failure, got %q", got)
45+
}
46+
}
47+
48+
func TestGCloudInjectorFromJSON_NilRequest(t *testing.T) {
49+
// Invalid JSON will cause init to fail, but nil check comes first
50+
inj := NewGCloudInjectorFromJSON([]byte("invalid json"))
51+
52+
if inj.Inject(nil) {
53+
t.Error("nil request should return false")
54+
}
55+
}
56+
57+
func TestGCloudInjectorFromJSON_NilHeader(t *testing.T) {
58+
inj := NewGCloudInjectorFromJSON([]byte("invalid json"))
59+
60+
req := &http.Request{
61+
Header: nil,
62+
}
63+
64+
if inj.Inject(req) {
65+
t.Error("request with nil Header should return false")
66+
}
67+
}

internal/credentials/static.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
package credentials
22

33
import (
4+
"log"
45
"net/http"
56
)
67

8+
// validateRequest checks if request is valid for credential injection.
9+
// Returns false if req or req.Header is nil, logging the injector name for debugging.
10+
func validateRequest(req *http.Request, injectorName string) bool {
11+
if req == nil || req.Header == nil {
12+
log.Printf("DEFENSIVE_CHECK: %s.Inject called with nil request or Header", injectorName)
13+
return false
14+
}
15+
return true
16+
}
17+
718
// HeaderInjector injects a static value into a specific header.
819
// Always overrides any existing value — the agent should never
920
// control which credentials are used.
@@ -15,6 +26,9 @@ type HeaderInjector struct {
1526
}
1627

1728
func (h *HeaderInjector) Inject(req *http.Request) bool {
29+
if !validateRequest(req, "HeaderInjector") {
30+
return false
31+
}
1832
req.Header.Set(h.Header, h.Value)
1933
return true
2034
}
@@ -26,6 +40,9 @@ type BearerInjector struct {
2640
}
2741

2842
func (b *BearerInjector) Inject(req *http.Request) bool {
43+
if !validateRequest(req, "BearerInjector") {
44+
return false
45+
}
2946
req.Header.Set("Authorization", "Bearer "+b.Token)
3047
return true
3148
}
@@ -38,6 +55,9 @@ type APIKeyInjector struct {
3855
}
3956

4057
func (a *APIKeyInjector) Inject(req *http.Request) bool {
58+
if !validateRequest(req, "APIKeyInjector") {
59+
return false
60+
}
4161
req.Header.Set(a.HeaderName, a.Key)
4262
return true
4363
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package credentials
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
)
7+
8+
func TestHeaderInjector_NilRequest(t *testing.T) {
9+
inj := &HeaderInjector{Header: "X-Custom", Value: "test"}
10+
11+
// Should handle nil request gracefully
12+
if inj.Inject(nil) {
13+
t.Error("nil request should return false")
14+
}
15+
}
16+
17+
func TestHeaderInjector_NilHeader(t *testing.T) {
18+
inj := &HeaderInjector{Header: "X-Custom", Value: "test"}
19+
20+
req := &http.Request{
21+
Header: nil,
22+
}
23+
24+
if inj.Inject(req) {
25+
t.Error("request with nil Header should return false")
26+
}
27+
}
28+
29+
func TestBearerInjector_NilRequest(t *testing.T) {
30+
inj := &BearerInjector{Token: "test-token"}
31+
32+
if inj.Inject(nil) {
33+
t.Error("nil request should return false")
34+
}
35+
}
36+
37+
func TestBearerInjector_NilHeader(t *testing.T) {
38+
inj := &BearerInjector{Token: "test-token"}
39+
40+
req := &http.Request{
41+
Header: nil,
42+
}
43+
44+
if inj.Inject(req) {
45+
t.Error("request with nil Header should return false")
46+
}
47+
}
48+
49+
func TestAPIKeyInjector_NilRequest(t *testing.T) {
50+
inj := &APIKeyInjector{HeaderName: "x-api-key", Key: "test-key"}
51+
52+
if inj.Inject(nil) {
53+
t.Error("nil request should return false")
54+
}
55+
}
56+
57+
func TestAPIKeyInjector_NilHeader(t *testing.T) {
58+
inj := &APIKeyInjector{HeaderName: "x-api-key", Key: "test-key"}
59+
60+
req := &http.Request{
61+
Header: nil,
62+
}
63+
64+
if inj.Inject(req) {
65+
t.Error("request with nil Header should return false")
66+
}
67+
}

internal/credentials/store.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ func (s *Store) InjectCredentials(req *http.Request) (bool, bool) {
5555
s.mu.RLock()
5656
defer s.mu.RUnlock()
5757

58+
if req == nil || req.URL == nil {
59+
return false, false
60+
}
61+
5862
host := req.URL.Host
5963
if idx := strings.LastIndex(host, ":"); idx != -1 {
6064
host = host[:idx]

internal/credentials/store_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,37 @@ func TestStore_InjectCredentials_InjectorFails(t *testing.T) {
169169
t.Errorf("Authorization should be empty, got %q", got)
170170
}
171171
}
172+
173+
// TestStore_InjectCredentials_NilRequest tests defensive nil checks
174+
func TestStore_InjectCredentials_NilRequest(t *testing.T) {
175+
store := NewStore()
176+
store.AddRoute(Route{
177+
ExactDomain: "example.com",
178+
Injector: &BearerInjector{Token: "test-token"},
179+
})
180+
181+
// Should handle nil request gracefully
182+
matched, injected := store.InjectCredentials(nil)
183+
if matched || injected {
184+
t.Error("nil request should return (false, false)")
185+
}
186+
}
187+
188+
func TestStore_InjectCredentials_NilURL(t *testing.T) {
189+
store := NewStore()
190+
store.AddRoute(Route{
191+
ExactDomain: "example.com",
192+
Injector: &BearerInjector{Token: "test-token"},
193+
})
194+
195+
// Request with nil URL
196+
req := &http.Request{
197+
URL: nil,
198+
Header: make(http.Header),
199+
}
200+
201+
matched, injected := store.InjectCredentials(req)
202+
if matched || injected {
203+
t.Error("request with nil URL should return (false, false)")
204+
}
205+
}

internal/credentials/token_vending.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ import (
88
"net/http"
99
)
1010

11+
// errorResponse creates an HTTP error response with plain text content.
12+
func errorResponse(statusCode int, message string) *http.Response {
13+
return &http.Response{
14+
StatusCode: statusCode,
15+
ProtoMajor: 1,
16+
ProtoMinor: 1,
17+
Header: http.Header{"Content-Type": {"text/plain"}},
18+
Body: io.NopCloser(bytes.NewReader([]byte(message))),
19+
ContentLength: int64(len(message)),
20+
}
21+
}
22+
1123
// TokenVendor intercepts OAuth2 token exchange requests from the agent's
1224
// Google Auth library and returns dummy tokens.
1325
//
@@ -36,6 +48,10 @@ type tokenResponse struct {
3648
// IsTokenExchange returns true if the request is an OAuth2 token exchange
3749
// to Google's token endpoint.
3850
func IsTokenExchange(req *http.Request) bool {
51+
if req == nil || req.URL == nil {
52+
return false
53+
}
54+
3955
host := req.URL.Host
4056
if host == "" {
4157
host = req.Host
@@ -51,6 +67,11 @@ func IsTokenExchange(req *http.Request) bool {
5167
// a dummy access token. The real token injection happens later via the
5268
// GCloudInjector when the agent makes API calls to *.googleapis.com.
5369
func (tv *TokenVendor) HandleTokenExchange(req *http.Request) *http.Response {
70+
if req == nil || req.URL == nil {
71+
log.Printf("DEFENSIVE_CHECK: HandleTokenExchange called with nil request or URL")
72+
return errorResponse(http.StatusBadRequest, "Malformed token exchange request")
73+
}
74+
5475
resp := &tokenResponse{
5576
AccessToken: "paude-proxy-managed",
5677
ExpiresIn: 3600,
@@ -60,7 +81,7 @@ func (tv *TokenVendor) HandleTokenExchange(req *http.Request) *http.Response {
6081
body, err := json.Marshal(resp)
6182
if err != nil {
6283
log.Printf("ERROR token vendor: marshal response: %v", err)
63-
return nil
84+
return errorResponse(http.StatusInternalServerError, "Internal token vendor error")
6485
}
6586

6687
log.Printf("TOKEN_VEND host=%s path=%s (returned dummy token, real injection at request time)", req.URL.Host, req.URL.Path)

0 commit comments

Comments
 (0)