Skip to content

Commit a435c04

Browse files
authored
feat(modules): add crlf and ssi injection detection modules (#347)
1 parent 91df2cc commit a435c04

3 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package modules_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"regexp"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/vmfunc/sif/internal/modules"
13+
)
14+
15+
const (
16+
crlfModule = "../../modules/http/crlf-injection.yaml"
17+
ssiModule = "../../modules/http/ssi-injection.yaml"
18+
)
19+
20+
func runInjectionModule(t *testing.T, file string, h http.Handler) *modules.Result {
21+
t.Helper()
22+
def, err := modules.ParseYAMLModule(file)
23+
if err != nil {
24+
t.Fatalf("parse %s: %v", file, err)
25+
}
26+
srv := httptest.NewServer(h)
27+
defer srv.Close()
28+
29+
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
30+
Timeout: 5 * time.Second,
31+
Threads: 4,
32+
})
33+
if err != nil {
34+
t.Fatalf("execute %s: %v", file, err)
35+
}
36+
return res
37+
}
38+
39+
// crlfHandler simulates a server that reflects request-supplied values into the
40+
// response header block. When sanitize is false it splits any value on CR/LF and
41+
// promotes trailing "Key: Value" lines to real headers, reproducing response
42+
// splitting. When sanitize is true it strips CR/LF first, so no header materializes.
43+
func crlfHandler(sanitize bool) http.HandlerFunc {
44+
newline := regexp.MustCompile(`\r\n|\r|\n`)
45+
return func(w http.ResponseWriter, r *http.Request) {
46+
var vals []string
47+
for _, vv := range r.URL.Query() {
48+
vals = append(vals, vv...)
49+
}
50+
vals = append(vals, r.URL.Path)
51+
52+
for _, v := range vals {
53+
if sanitize {
54+
v = strings.NewReplacer("\r", "", "\n", "").Replace(v)
55+
}
56+
segs := newline.Split(v, -1)
57+
if len(segs) < 2 {
58+
continue
59+
}
60+
for _, seg := range segs[1:] {
61+
idx := strings.Index(seg, ":")
62+
if idx <= 0 {
63+
continue
64+
}
65+
w.Header().Set(strings.TrimSpace(seg[:idx]), strings.TrimSpace(seg[idx+1:]))
66+
}
67+
}
68+
_, _ = w.Write([]byte("ok"))
69+
}
70+
}
71+
72+
// ssiHandler simulates SSI processing of reflected input. When render is true it
73+
// replaces an echo directive with a live GMT date (as Apache mod_include would),
74+
// consuming the directive. When render is false it echoes the value literally.
75+
func ssiHandler(render bool) http.HandlerFunc {
76+
directive := regexp.MustCompile(`<!--#echo var="[A-Z_]+"-->`)
77+
return func(w http.ResponseWriter, r *http.Request) {
78+
v := r.URL.Query().Get("q")
79+
if render {
80+
v = directive.ReplaceAllString(v, "Wednesday, 08-Jul-2026 14:30:00 GMT")
81+
}
82+
_, _ = w.Write([]byte("<html><body>" + v + "</body></html>"))
83+
}
84+
}
85+
86+
// ssiEntityHandler reflects the decoded value but HTML-encodes the metacharacters
87+
// with NUMERIC character references ("<" -> "&#60;"). This is the adversarial
88+
// case: the escape itself carries digits with no literal "<", which a naive
89+
// "[^<]*\d[^<]*" regex would wrongly match.
90+
func ssiEntityHandler() http.HandlerFunc {
91+
rep := strings.NewReplacer("<", "&#60;", ">", "&#62;", "\"", "&#34;")
92+
return func(w http.ResponseWriter, r *http.Request) {
93+
_, _ = w.Write([]byte("<html><body>" + rep.Replace(r.URL.Query().Get("q")) + "</body></html>"))
94+
}
95+
}
96+
97+
// ssiRawHandler reflects the RAW (still percent-encoded) query value, as an app
98+
// that echoes the query string verbatim would ("no results for %3C..."). The
99+
// "%3C" escapes carry digits with no literal "<".
100+
func ssiRawHandler() http.HandlerFunc {
101+
return func(w http.ResponseWriter, r *http.Request) {
102+
raw := r.URL.RawQuery
103+
if i := strings.IndexByte(raw, '='); i >= 0 {
104+
raw = raw[i+1:]
105+
}
106+
if i := strings.IndexByte(raw, '&'); i >= 0 {
107+
raw = raw[:i]
108+
}
109+
_, _ = w.Write([]byte("<html><body>no results for " + raw + "</body></html>"))
110+
}
111+
}
112+
113+
func TestCRLFInjectionModule(t *testing.T) {
114+
t.Run("injected header materializes", func(t *testing.T) {
115+
res := runInjectionModule(t, crlfModule, crlfHandler(false))
116+
if len(res.Findings) == 0 {
117+
t.Fatal("expected a crlf finding when the injected header is reflected")
118+
}
119+
})
120+
121+
t.Run("sanitized response is not flagged", func(t *testing.T) {
122+
res := runInjectionModule(t, crlfModule, crlfHandler(true))
123+
if len(res.Findings) != 0 {
124+
t.Fatalf("got %d findings on a sanitizing server, want 0", len(res.Findings))
125+
}
126+
})
127+
128+
t.Run("body-only reflection is not flagged", func(t *testing.T) {
129+
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130+
_, _ = w.Write([]byte(r.URL.RawQuery))
131+
})
132+
res := runInjectionModule(t, crlfModule, echo)
133+
if len(res.Findings) != 0 {
134+
t.Fatalf("got %d findings on body-only reflection, want 0", len(res.Findings))
135+
}
136+
})
137+
138+
// a non-splitting server that echoes the param into an unrelated header
139+
// value: go collapses the CR/LF so no header line is added, but the literal
140+
// "X-Sif-Injected" text survives inside that value. a line-anchored matcher
141+
// must not treat that mid-line text as an injected header.
142+
t.Run("header-value reflection is not flagged", func(t *testing.T) {
143+
reflect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144+
for _, vv := range r.URL.Query() {
145+
for _, v := range vv {
146+
w.Header().Set("X-Echo-Request", v)
147+
}
148+
}
149+
_, _ = w.Write([]byte("ok"))
150+
})
151+
res := runInjectionModule(t, crlfModule, reflect)
152+
if len(res.Findings) != 0 {
153+
t.Fatalf("got %d findings on header-value reflection, want 0", len(res.Findings))
154+
}
155+
})
156+
}
157+
158+
func TestSSIInjectionModule(t *testing.T) {
159+
t.Run("rendered directive is flagged", func(t *testing.T) {
160+
res := runInjectionModule(t, ssiModule, ssiHandler(true))
161+
if len(res.Findings) == 0 {
162+
t.Fatal("expected an ssi finding when the echo directive renders a date")
163+
}
164+
})
165+
166+
t.Run("literal echo is not flagged", func(t *testing.T) {
167+
res := runInjectionModule(t, ssiModule, ssiHandler(false))
168+
if len(res.Findings) != 0 {
169+
t.Fatalf("got %d findings when the directive is echoed literally, want 0", len(res.Findings))
170+
}
171+
})
172+
173+
t.Run("numeric-entity reflection is not flagged", func(t *testing.T) {
174+
res := runInjectionModule(t, ssiModule, ssiEntityHandler())
175+
if len(res.Findings) != 0 {
176+
t.Fatalf("got %d findings on numeric-entity reflection, want 0", len(res.Findings))
177+
}
178+
})
179+
180+
t.Run("raw percent reflection is not flagged", func(t *testing.T) {
181+
res := runInjectionModule(t, ssiModule, ssiRawHandler())
182+
if len(res.Findings) != 0 {
183+
t.Fatalf("got %d findings on raw percent-encoded reflection, want 0", len(res.Findings))
184+
}
185+
})
186+
187+
// literal reflection plus a digit elsewhere on the page: the "<" of the
188+
// echoed directive sits between the markers, so no cross-sentinel match.
189+
t.Run("reflection with unrelated digit is not flagged", func(t *testing.T) {
190+
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191+
_, _ = w.Write([]byte("<p>page 7</p>" + r.URL.Query().Get("q")))
192+
})
193+
res := runInjectionModule(t, ssiModule, h)
194+
if len(res.Findings) != 0 {
195+
t.Fatalf("got %d findings on reflection beside an unrelated digit, want 0", len(res.Findings))
196+
}
197+
})
198+
}

modules/http/crlf-injection.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# CRLF Injection / HTTP Response Splitting Detection Module
2+
3+
id: crlf-injection
4+
info:
5+
name: CRLF Injection Detection
6+
author: sif
7+
severity: medium
8+
description: Detects CRLF injection and HTTP response splitting via header reflection
9+
tags: [crlf, injection, response-splitting, owasp-top10]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/?url={{payload}}"
17+
- "{{BaseURL}}/?redirect={{payload}}"
18+
- "{{BaseURL}}/?next={{payload}}"
19+
- "{{BaseURL}}/?page={{payload}}"
20+
- "{{BaseURL}}/?dest={{payload}}"
21+
- "{{BaseURL}}/?r={{payload}}"
22+
- "{{BaseURL}}/{{payload}}"
23+
24+
payloads:
25+
- "%0d%0aX-Sif-Injected:1"
26+
- "%0d%0aX-Sif-Injected%3A1"
27+
- "%0aX-Sif-Injected:1"
28+
- "test%0d%0aX-Sif-Injected:1"
29+
30+
threads: 10
31+
32+
# proof: anchor to a header LINE that starts with our injected key. a real
33+
# split promotes "X-Sif-Injected:1" to its own header line; reflecting the
34+
# payload into some other header's VALUE (go rewrites the CR/LF to nothing, so
35+
# no split) leaves the text mid-line, where "^" cannot match. getPart renders
36+
# one "Key: value" per line, so line-start distinguishes split from reflection.
37+
matchers:
38+
- type: regex
39+
part: header
40+
regex:
41+
- "(?m)^X-Sif-Injected:"

modules/http/ssi-injection.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Server-Side Includes (SSI) Injection Detection Module
2+
3+
id: ssi-injection
4+
info:
5+
name: Server-Side Includes Injection Detection
6+
author: sif
7+
severity: high
8+
description: Detects Server-Side Includes injection via directive evaluation
9+
tags: [ssi, injection, server-side-includes]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/?q={{payload}}"
17+
- "{{BaseURL}}/?search={{payload}}"
18+
- "{{BaseURL}}/?name={{payload}}"
19+
- "{{BaseURL}}/?page={{payload}}"
20+
- "{{BaseURL}}/?comment={{payload}}"
21+
- "{{BaseURL}}/?msg={{payload}}"
22+
23+
# the echo directive carries no digit, so a digit appearing between our two
24+
# markers can only come from a rendered date. the payload is wrapped so the
25+
# value is url-safe on the wire and the server decodes it before evaluation.
26+
payloads:
27+
- "SIFSSIA%3C!--%23echo%20var%3D%22DATE_GMT%22--%3ESIFSSIZ"
28+
- "SIFSSIA%3C!--%23echo%20var%3D%22DATE_LOCAL%22--%3ESIFSSIZ"
29+
30+
threads: 10
31+
32+
# proof: a digit must sit between the markers with no "<", "&" or "%" between
33+
# them. the directive carries no digit of its own, so every digit-bearing
34+
# reflection reaches us through an escape that our excluded set blocks: literal
35+
# ("<"), html entity named or numeric ("&lt;", "&#60;" -> "&"), or raw percent
36+
# ("%3C" -> "%"). a rendered GMT date has none of those, so only genuine SSI
37+
# evaluation (the directive replaced by a date) can satisfy this regex.
38+
matchers:
39+
- type: regex
40+
part: body
41+
regex:
42+
- "SIFSSIA[^<&%]*\\d[^<&%]*SIFSSIZ"

0 commit comments

Comments
 (0)