Skip to content

Commit 9fe2dec

Browse files
ersinkocclaude
andcommitted
fix: SSRF IPv6 host extraction bug, add WAF edge case tests
Fix extractHost() to properly strip IPv6 brackets before net.ParseIP, enabling detection of private IPv6 addresses (ULA fd00::/8, loopback ::1) and AWS IPv6 metadata endpoint. Also fix isInternalHost() to strip brackets. Add 12 SSRF edge case tests (IPv6 loopback/ULA, AWS IPv6 metadata, mixed-case hostname, short IP forms, Alibaba/GCP metadata, multiple URLs, 172.x boundary, link-local, credential bypass external, URL with port). Add 7 path traversal edge case tests (mixed encoding, repeated encoded traversal, encoded dots in query, null byte + traversal, backslash traversal, encoded backslash dots). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5a17ee2 commit 9fe2dec

4 files changed

Lines changed: 277 additions & 17 deletions

File tree

.project/ROADMAP.md

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,8 @@
2222

2323
**Priority: CRITICAL** — These affect any deployment exposed to untrusted traffic.
2424

25-
### 1.1 WAF SSRF Detection
26-
- **File:** `internal/waf/detection/ssrf.go`
27-
- **Status:** MOSTLY DONE — IPv6 loopback/ULA, decimal/octal IP regex, cloud metadata hosts (AWS/GCP/Alibaba/AWS IPv6) already implemented. Minor edge cases may remain.
28-
- **Remaining:** Verify Azure/DigitalOcean metadata IPs, add additional edge case tests
29-
- **Effort:** 2-3 hours (reduced from 1 day)
25+
### ~~1.1 WAF SSRF Detection~~ (FIXED)
26+
- **Status:** Fixed IPv6 host extraction bug in `extractHost` (brackets prevented `net.ParseIP` from matching). Azure/DigitalOcean metadata already covered by `169.254.169.254`. Added edge case tests: IPv6 loopback, IPv6 ULA, AWS IPv6 metadata, mixed-case hostname, short IP forms, Alibaba metadata, GCP metadata, multiple URLs, 172.x boundary values, link-local, credential bypass to external, URL with port.
3027

3128
### ~~1.2 MCP SSE Transport CORS~~ (FALSE POSITIVE)
3229
- **Status:** ALREADY IMPLEMENTED — `sse_transport.go` has configurable `AllowedOrigins` list with `Vary: Origin` and `Access-Control-Allow-Credentials: true`. No wildcard CORS.
@@ -37,10 +34,8 @@
3734
### ~~1.4 PROXY Protocol Trusted Upstreams~~ (FALSE POSITIVE)
3835
- **Status:** ALREADY IMPLEMENTED — `PROXYProtocolConfig.TrustedNetworks` CIDR list with `isTrustedSource()` method. Default: trust no one (empty list = reject all PROXY headers).
3936

40-
### 1.5 Path Traversal Verification
41-
- **File:** `internal/router/match.go`
42-
- **Work:** Add comprehensive edge case tests for URL-encoded path traversal: `%2e%2e/%2e%2e`, `..%2f`, `%2e%2e%5c`, double encoding. Verify `normalizePath` handles all variants.
43-
- **Effort:** 3 hours
37+
### ~~1.5 Path Traversal Verification~~ (FIXED)
38+
- **Status:** Added edge case tests for mixed encoding (literal + encoded slash, encoded dots + literal, case-insensitive `%2E%2E%2F`), repeated encoded traversal, encoded dots in query params, null byte with traversal, backslash traversal, encoded backslash dots. All existing and new tests pass.
4439

4540
---
4641

internal/waf/detection/pathtraversal/pathtraversal_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,98 @@ func TestPathTraversal_FullyEncodedDots(t *testing.T) {
411411
})
412412
}
413413
}
414+
415+
func TestPathTraversal_MixedEncoding(t *testing.T) {
416+
d := New()
417+
tests := []struct {
418+
name string
419+
path string
420+
rule string
421+
}{
422+
{"literal + encoded slash", "/..%2f..%2fsafe", "encoded_traversal"},
423+
{"encoded dots + literal", "/%2e%2e/../safe", "encoded_traversal"},
424+
{"case-insensitive encoding", "/%2E%2E%2Fsafe", "encoded_traversal"},
425+
{"case-insensitive dots", "/..%2F..%2Fsafe", "encoded_traversal"},
426+
}
427+
for _, tt := range tests {
428+
t.Run(tt.name, func(t *testing.T) {
429+
ctx := newCtx(tt.path, "")
430+
findings := d.Detect(ctx)
431+
if len(findings) == 0 {
432+
t.Fatalf("expected detection for %q", tt.path)
433+
}
434+
found := false
435+
for _, f := range findings {
436+
if f.Rule == tt.rule {
437+
found = true
438+
}
439+
}
440+
if !found {
441+
t.Errorf("expected rule %q for %q, got rules: %v", tt.rule, tt.path, findingsToRules(findings))
442+
}
443+
})
444+
}
445+
}
446+
447+
func TestPathTraversal_RepeatedEncodedTraversal(t *testing.T) {
448+
d := New()
449+
// Deep traversal with all encoded components
450+
ctx := newCtx("/%2e%2e/%2e%2e/%2e%2e/safe", "")
451+
findings := d.Detect(ctx)
452+
if len(findings) == 0 {
453+
t.Error("expected detection for repeated encoded traversal")
454+
}
455+
}
456+
457+
func TestPathTraversal_EncodedDotsInQuery(t *testing.T) {
458+
d := New()
459+
ctx := newCtx("/page", "file=%2e%2e/%2e%2e/safe")
460+
findings := d.Detect(ctx)
461+
if len(findings) == 0 {
462+
t.Error("expected detection for encoded dots in query")
463+
}
464+
found := false
465+
for _, f := range findings {
466+
if f.Rule == "encoded_traversal" {
467+
found = true
468+
}
469+
}
470+
if !found {
471+
t.Errorf("expected encoded_traversal rule in query, got: %v", findingsToRules(findings))
472+
}
473+
}
474+
475+
func TestPathTraversal_NullByteWithTraversal(t *testing.T) {
476+
d := New()
477+
ctx := newCtx("/../safe%00.jpg", "")
478+
findings := d.Detect(ctx)
479+
if len(findings) == 0 {
480+
t.Error("expected detection for null byte with traversal")
481+
}
482+
}
483+
484+
func TestPathTraversal_BackslashTraversal(t *testing.T) {
485+
d := New()
486+
ctx := newCtx("/..\\..\\safe", "")
487+
findings := d.Detect(ctx)
488+
if len(findings) == 0 {
489+
t.Error("expected detection for backslash traversal")
490+
}
491+
}
492+
493+
func TestPathTraversal_EncodedBackslashDots(t *testing.T) {
494+
d := New()
495+
ctx := newCtx("/%2e%2e%5c%2e%2e%5csafe", "")
496+
findings := d.Detect(ctx)
497+
if len(findings) == 0 {
498+
t.Error("expected detection for encoded backslash traversal")
499+
}
500+
}
501+
502+
func findingsToRules(findings []detection.Finding) []string {
503+
rules := make([]string, len(findings))
504+
for i, f := range findings {
505+
rules[i] = f.Rule
506+
}
507+
return rules
508+
}

internal/waf/detection/ssrf/ipcheck.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,21 @@ func isPrivateIP(ip net.IP) bool {
3838
// isInternalHost checks if a host string resolves to an internal/private address.
3939
func isInternalHost(host string) bool {
4040
lower := strings.ToLower(host)
41-
if lower == "localhost" || lower == "127.0.0.1" || lower == "[::1]" || lower == "0.0.0.0" {
41+
// Strip IPv6 brackets if present
42+
lower = strings.TrimPrefix(lower, "[")
43+
lower = strings.TrimSuffix(lower, "]")
44+
if lower == "localhost" || lower == "127.0.0.1" || lower == "::1" || lower == "0.0.0.0" {
4245
return true
4346
}
44-
ip := net.ParseIP(host)
47+
ip := net.ParseIP(lower)
4548
if ip != nil {
4649
return isPrivateIP(ip)
4750
}
4851
return false
4952
}
5053

5154
// extractHost extracts the hostname from a URL string.
55+
// Strips brackets from IPv6 addresses so the result is parseable by net.ParseIP.
5256
func extractHost(u string) string {
5357
s := u
5458
if idx := strings.Index(s, "://"); idx >= 0 {
@@ -57,14 +61,19 @@ func extractHost(u string) string {
5761
if idx := strings.IndexByte(s, '/'); idx >= 0 {
5862
s = s[:idx]
5963
}
60-
if idx := strings.LastIndexByte(s, ':'); idx >= 0 {
61-
if !strings.Contains(s, "[") {
62-
s = s[:idx]
63-
}
64-
}
6564
if idx := strings.IndexByte(s, '@'); idx >= 0 {
6665
s = s[idx+1:]
6766
}
67+
// Strip IPv6 brackets: [::1]:80 -> ::1
68+
if strings.Contains(s, "[") {
69+
s = strings.TrimPrefix(s, "[")
70+
if closeIdx := strings.IndexByte(s, ']'); closeIdx >= 0 {
71+
s = s[:closeIdx]
72+
}
73+
} else if idx := strings.LastIndexByte(s, ':'); idx >= 0 {
74+
// Strip port for non-IPv6 hosts
75+
s = s[:idx]
76+
}
6877
return s
6978
}
7079

internal/waf/detection/ssrf/ssrf_test.go

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func TestSSRFDetector_ExtractHost(t *testing.T) {
150150
{"http://example.com/path", "example.com"},
151151
{"https://10.0.0.1:8080/api", "10.0.0.1"},
152152
{"http://user@localhost/admin", "localhost"},
153-
{"http://[::1]:80/path", "[::1]:80"},
153+
{"http://[::1]:80/path", "::1"},
154154
{"http://hostname", "hostname"},
155155
}
156156
d := New()
@@ -207,6 +207,7 @@ func TestSSRFDetector_IsInternalHost(t *testing.T) {
207207
{"localhost", true},
208208
{"127.0.0.1", true},
209209
{"[::1]", true},
210+
{"::1", true},
210211
{"0.0.0.0", true},
211212
{"10.0.0.1", true},
212213
{"192.168.1.1", true},
@@ -289,3 +290,163 @@ func TestSSRFDetector_Truncate(t *testing.T) {
289290
t.Errorf("expected length 83, got %d", len(long))
290291
}
291292
}
293+
294+
func TestSSRFDetector_IPv6Loopback(t *testing.T) {
295+
d := New()
296+
ctx := newCtx("url=http://[::1]/admin")
297+
findings := d.Detect(ctx)
298+
if len(findings) == 0 {
299+
t.Error("expected SSRF detection for IPv6 loopback [::1]")
300+
}
301+
}
302+
303+
func TestSSRFDetector_IPv6ULA(t *testing.T) {
304+
d := New()
305+
ctx := newCtx("url=http://[fd00::1]/internal")
306+
findings := d.Detect(ctx)
307+
if len(findings) == 0 {
308+
t.Error("expected SSRF detection for IPv6 ULA fd00::1")
309+
}
310+
}
311+
312+
func TestSSRFDetector_AWSIPv6Metadata(t *testing.T) {
313+
d := New()
314+
ctx := newCtx("url=http://[fd00:ec2::254]/latest/meta-data/")
315+
findings := d.Detect(ctx)
316+
if len(findings) == 0 {
317+
t.Error("expected SSRF detection for AWS IPv6 metadata endpoint")
318+
}
319+
}
320+
321+
func TestSSRFDetector_MixedCaseHostname(t *testing.T) {
322+
d := New()
323+
ctx := newCtx("url=http://LoCaLhOsT/admin")
324+
findings := d.Detect(ctx)
325+
if len(findings) == 0 {
326+
t.Error("expected SSRF detection for mixed-case localhost")
327+
}
328+
}
329+
330+
func TestSSRFDetector_ShortIPForms(t *testing.T) {
331+
d := New()
332+
tests := []struct {
333+
name string
334+
input string
335+
}{
336+
{"zero ip", "url=http://0/"},
337+
{"zero zero zero zero", "url=http://0.0.0.0/"},
338+
}
339+
for _, tt := range tests {
340+
t.Run(tt.name, func(t *testing.T) {
341+
ctx := newCtx(tt.input)
342+
findings := d.Detect(ctx)
343+
if len(findings) == 0 {
344+
t.Errorf("expected SSRF detection for %q", tt.input)
345+
}
346+
})
347+
}
348+
}
349+
350+
func TestSSRFDetector_AlibabaMetadata(t *testing.T) {
351+
d := New()
352+
ctx := newCtx("url=http://100.100.100.200/latest/meta-data/")
353+
findings := d.Detect(ctx)
354+
if len(findings) == 0 {
355+
t.Error("expected SSRF detection for Alibaba Cloud metadata 100.100.100.200")
356+
}
357+
}
358+
359+
func TestSSRFDetector_GCPMetadata(t *testing.T) {
360+
d := New()
361+
tests := []struct {
362+
name string
363+
input string
364+
}{
365+
{"google.internal", "url=http://metadata.google.internal/computeMetadata/v1/"},
366+
{"metadata.google", "url=http://metadata.google/computeMetadata/v1/"},
367+
}
368+
for _, tt := range tests {
369+
t.Run(tt.name, func(t *testing.T) {
370+
ctx := newCtx(tt.input)
371+
findings := d.Detect(ctx)
372+
if len(findings) == 0 {
373+
t.Errorf("expected SSRF detection for GCP metadata %q", tt.input)
374+
}
375+
found := false
376+
for _, f := range findings {
377+
if f.Rule == "cloud_metadata" {
378+
found = true
379+
if f.Score < 90 {
380+
t.Errorf("expected score >= 90 for cloud metadata, got %d", f.Score)
381+
}
382+
}
383+
}
384+
if !found {
385+
t.Error("expected cloud_metadata rule")
386+
}
387+
})
388+
}
389+
}
390+
391+
func TestSSRFDetector_MultipleURLs(t *testing.T) {
392+
d := New()
393+
ctx := newCtx("url=http://example.com/safe callback=http://127.0.0.1/secret")
394+
findings := d.Detect(ctx)
395+
if len(findings) == 0 {
396+
t.Error("expected SSRF detection when second URL targets localhost")
397+
}
398+
}
399+
400+
func TestSSRFDetector_Private172Range(t *testing.T) {
401+
// Test boundary values of 172.16-31 range
402+
tests := []struct {
403+
ip string
404+
private bool
405+
}{
406+
{"172.16.0.1", true},
407+
{"172.20.0.1", true},
408+
{"172.31.255.255", true},
409+
{"172.15.255.255", false},
410+
{"172.32.0.1", false},
411+
}
412+
for _, tt := range tests {
413+
ip := net.ParseIP(tt.ip)
414+
got := isPrivateIP(ip)
415+
if got != tt.private {
416+
t.Errorf("isPrivateIP(%q) = %v, want %v", tt.ip, got, tt.private)
417+
}
418+
}
419+
}
420+
421+
func TestSSRFDetector_LinkLocal(t *testing.T) {
422+
d := New()
423+
// 169.254.x.x is link-local (used for cloud metadata)
424+
ctx := newCtx("url=http://169.254.0.1/")
425+
findings := d.Detect(ctx)
426+
if len(findings) == 0 {
427+
t.Error("expected detection for link-local IP 169.254.0.1")
428+
}
429+
}
430+
431+
func TestSSRFDetector_CredentialBypassExternal(t *testing.T) {
432+
d := New()
433+
// Credential bypass to external host should not trigger
434+
ctx := newCtx("url=http://user:pass@example.com/api")
435+
findings := d.Detect(ctx)
436+
totalScore := 0
437+
for _, f := range findings {
438+
totalScore += f.Score
439+
}
440+
if totalScore >= 50 {
441+
t.Errorf("expected no significant SSRF for credential URL to external host, got score %d", totalScore)
442+
}
443+
}
444+
445+
func TestSSRFDetector_URLWithPort(t *testing.T) {
446+
d := New()
447+
ctx := newCtx("url=http://127.0.0.1:8080/admin")
448+
findings := d.Detect(ctx)
449+
if len(findings) == 0 {
450+
t.Error("expected SSRF detection for localhost with port")
451+
}
452+
}

0 commit comments

Comments
 (0)