feat(modules): add crlf and ssi injection detection modules (#347)

This commit is contained in:
Tigah
2026-07-22 12:55:41 -07:00
committed by GitHub
parent 91df2ccd56
commit a435c048a4
3 changed files with 281 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
const (
crlfModule = "../../modules/http/crlf-injection.yaml"
ssiModule = "../../modules/http/ssi-injection.yaml"
)
func runInjectionModule(t *testing.T, file string, h http.Handler) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(h)
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 4,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
// crlfHandler simulates a server that reflects request-supplied values into the
// response header block. When sanitize is false it splits any value on CR/LF and
// promotes trailing "Key: Value" lines to real headers, reproducing response
// splitting. When sanitize is true it strips CR/LF first, so no header materializes.
func crlfHandler(sanitize bool) http.HandlerFunc {
newline := regexp.MustCompile(`\r\n|\r|\n`)
return func(w http.ResponseWriter, r *http.Request) {
var vals []string
for _, vv := range r.URL.Query() {
vals = append(vals, vv...)
}
vals = append(vals, r.URL.Path)
for _, v := range vals {
if sanitize {
v = strings.NewReplacer("\r", "", "\n", "").Replace(v)
}
segs := newline.Split(v, -1)
if len(segs) < 2 {
continue
}
for _, seg := range segs[1:] {
idx := strings.Index(seg, ":")
if idx <= 0 {
continue
}
w.Header().Set(strings.TrimSpace(seg[:idx]), strings.TrimSpace(seg[idx+1:]))
}
}
_, _ = w.Write([]byte("ok"))
}
}
// ssiHandler simulates SSI processing of reflected input. When render is true it
// replaces an echo directive with a live GMT date (as Apache mod_include would),
// consuming the directive. When render is false it echoes the value literally.
func ssiHandler(render bool) http.HandlerFunc {
directive := regexp.MustCompile(`<!--#echo var="[A-Z_]+"-->`)
return func(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("q")
if render {
v = directive.ReplaceAllString(v, "Wednesday, 08-Jul-2026 14:30:00 GMT")
}
_, _ = w.Write([]byte("<html><body>" + v + "</body></html>"))
}
}
// ssiEntityHandler reflects the decoded value but HTML-encodes the metacharacters
// with NUMERIC character references ("<" -> "&#60;"). This is the adversarial
// case: the escape itself carries digits with no literal "<", which a naive
// "[^<]*\d[^<]*" regex would wrongly match.
func ssiEntityHandler() http.HandlerFunc {
rep := strings.NewReplacer("<", "&#60;", ">", "&#62;", "\"", "&#34;")
return func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("<html><body>" + rep.Replace(r.URL.Query().Get("q")) + "</body></html>"))
}
}
// ssiRawHandler reflects the RAW (still percent-encoded) query value, as an app
// that echoes the query string verbatim would ("no results for %3C..."). The
// "%3C" escapes carry digits with no literal "<".
func ssiRawHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
raw := r.URL.RawQuery
if i := strings.IndexByte(raw, '='); i >= 0 {
raw = raw[i+1:]
}
if i := strings.IndexByte(raw, '&'); i >= 0 {
raw = raw[:i]
}
_, _ = w.Write([]byte("<html><body>no results for " + raw + "</body></html>"))
}
}
func TestCRLFInjectionModule(t *testing.T) {
t.Run("injected header materializes", func(t *testing.T) {
res := runInjectionModule(t, crlfModule, crlfHandler(false))
if len(res.Findings) == 0 {
t.Fatal("expected a crlf finding when the injected header is reflected")
}
})
t.Run("sanitized response is not flagged", func(t *testing.T) {
res := runInjectionModule(t, crlfModule, crlfHandler(true))
if len(res.Findings) != 0 {
t.Fatalf("got %d findings on a sanitizing server, want 0", len(res.Findings))
}
})
t.Run("body-only reflection is not flagged", func(t *testing.T) {
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(r.URL.RawQuery))
})
res := runInjectionModule(t, crlfModule, echo)
if len(res.Findings) != 0 {
t.Fatalf("got %d findings on body-only reflection, want 0", len(res.Findings))
}
})
// a non-splitting server that echoes the param into an unrelated header
// value: go collapses the CR/LF so no header line is added, but the literal
// "X-Sif-Injected" text survives inside that value. a line-anchored matcher
// must not treat that mid-line text as an injected header.
t.Run("header-value reflection is not flagged", func(t *testing.T) {
reflect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, vv := range r.URL.Query() {
for _, v := range vv {
w.Header().Set("X-Echo-Request", v)
}
}
_, _ = w.Write([]byte("ok"))
})
res := runInjectionModule(t, crlfModule, reflect)
if len(res.Findings) != 0 {
t.Fatalf("got %d findings on header-value reflection, want 0", len(res.Findings))
}
})
}
func TestSSIInjectionModule(t *testing.T) {
t.Run("rendered directive is flagged", func(t *testing.T) {
res := runInjectionModule(t, ssiModule, ssiHandler(true))
if len(res.Findings) == 0 {
t.Fatal("expected an ssi finding when the echo directive renders a date")
}
})
t.Run("literal echo is not flagged", func(t *testing.T) {
res := runInjectionModule(t, ssiModule, ssiHandler(false))
if len(res.Findings) != 0 {
t.Fatalf("got %d findings when the directive is echoed literally, want 0", len(res.Findings))
}
})
t.Run("numeric-entity reflection is not flagged", func(t *testing.T) {
res := runInjectionModule(t, ssiModule, ssiEntityHandler())
if len(res.Findings) != 0 {
t.Fatalf("got %d findings on numeric-entity reflection, want 0", len(res.Findings))
}
})
t.Run("raw percent reflection is not flagged", func(t *testing.T) {
res := runInjectionModule(t, ssiModule, ssiRawHandler())
if len(res.Findings) != 0 {
t.Fatalf("got %d findings on raw percent-encoded reflection, want 0", len(res.Findings))
}
})
// literal reflection plus a digit elsewhere on the page: the "<" of the
// echoed directive sits between the markers, so no cross-sentinel match.
t.Run("reflection with unrelated digit is not flagged", func(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("<p>page 7</p>" + r.URL.Query().Get("q")))
})
res := runInjectionModule(t, ssiModule, h)
if len(res.Findings) != 0 {
t.Fatalf("got %d findings on reflection beside an unrelated digit, want 0", len(res.Findings))
}
})
}
+41
View File
@@ -0,0 +1,41 @@
# CRLF Injection / HTTP Response Splitting Detection Module
id: crlf-injection
info:
name: CRLF Injection Detection
author: sif
severity: medium
description: Detects CRLF injection and HTTP response splitting via header reflection
tags: [crlf, injection, response-splitting, owasp-top10]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/?url={{payload}}"
- "{{BaseURL}}/?redirect={{payload}}"
- "{{BaseURL}}/?next={{payload}}"
- "{{BaseURL}}/?page={{payload}}"
- "{{BaseURL}}/?dest={{payload}}"
- "{{BaseURL}}/?r={{payload}}"
- "{{BaseURL}}/{{payload}}"
payloads:
- "%0d%0aX-Sif-Injected:1"
- "%0d%0aX-Sif-Injected%3A1"
- "%0aX-Sif-Injected:1"
- "test%0d%0aX-Sif-Injected:1"
threads: 10
# proof: anchor to a header LINE that starts with our injected key. a real
# split promotes "X-Sif-Injected:1" to its own header line; reflecting the
# payload into some other header's VALUE (go rewrites the CR/LF to nothing, so
# no split) leaves the text mid-line, where "^" cannot match. getPart renders
# one "Key: value" per line, so line-start distinguishes split from reflection.
matchers:
- type: regex
part: header
regex:
- "(?m)^X-Sif-Injected:"
+42
View File
@@ -0,0 +1,42 @@
# Server-Side Includes (SSI) Injection Detection Module
id: ssi-injection
info:
name: Server-Side Includes Injection Detection
author: sif
severity: high
description: Detects Server-Side Includes injection via directive evaluation
tags: [ssi, injection, server-side-includes]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/?q={{payload}}"
- "{{BaseURL}}/?search={{payload}}"
- "{{BaseURL}}/?name={{payload}}"
- "{{BaseURL}}/?page={{payload}}"
- "{{BaseURL}}/?comment={{payload}}"
- "{{BaseURL}}/?msg={{payload}}"
# the echo directive carries no digit, so a digit appearing between our two
# markers can only come from a rendered date. the payload is wrapped so the
# value is url-safe on the wire and the server decodes it before evaluation.
payloads:
- "SIFSSIA%3C!--%23echo%20var%3D%22DATE_GMT%22--%3ESIFSSIZ"
- "SIFSSIA%3C!--%23echo%20var%3D%22DATE_LOCAL%22--%3ESIFSSIZ"
threads: 10
# proof: a digit must sit between the markers with no "<", "&" or "%" between
# them. the directive carries no digit of its own, so every digit-bearing
# reflection reaches us through an escape that our excluded set blocks: literal
# ("<"), html entity named or numeric ("&lt;", "&#60;" -> "&"), or raw percent
# ("%3C" -> "%"). a rendered GMT date has none of those, so only genuine SSI
# evaluation (the directive replaced by a date) can satisfy this regex.
matchers:
- type: regex
part: body
regex:
- "SIFSSIA[^<&%]*\\d[^<&%]*SIFSSIZ"