Skip to content

Commit d4d7a4e

Browse files
authored
Merge pull request #16 from turenlabs/feat/security-coverage
Add security coverage: taint entries, rules, interproc proofs
2 parents 335e249 + 6c93072 commit d4d7a4e

15 files changed

Lines changed: 2361 additions & 0 deletions
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package encoding
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/turenlabs/batou/internal/rules"
8+
)
9+
10+
// ---------------------------------------------------------------------------
11+
// Compiled regex patterns -- Encoding extensions
12+
// ---------------------------------------------------------------------------
13+
14+
// BATOU-ENC-009: UTF-7 XSS bypass (Content-Type without charset)
15+
var (
16+
// batou:ignore BATOU-LOG-004 -- regex pattern definition, not logging
17+
reContentTypeHTMLNoCharset = regexp.MustCompile(`(?i)Content-Type['":\s]*text/html\s*['"]?\s*[;)\]}]?\s*$`)
18+
reContentTypeSetHTML = regexp.MustCompile(`(?i)(?:\.setHeader|\.header|\.set|Header\(\)\.Set|\.writeHead|res\.type)\s*\([^)]*text/html`)
19+
reCharsetPresent = regexp.MustCompile(`(?i)charset\s*=`)
20+
reUserOutputNearby = regexp.MustCompile(`(?i)(?:res\.|response\.|write|send|render|print|echo|puts)\s*\(`)
21+
)
22+
23+
// BATOU-ENC-010: Overlong UTF-8 / decode without normalization near file ops
24+
var (
25+
reDecodeURI = regexp.MustCompile(`(?i)(?:decodeURIComponent|decodeURI|unescape|urllib\.unquote|url\.QueryUnescape|URLDecoder\.decode|rawurldecode|urldecode|CGI\.unescape|Uri\.UnescapeDataString)\s*\(`)
26+
reFilePathOp = regexp.MustCompile(`(?i)(?:readFile|writeFile|createReadStream|open\s*\(|path\.join|path\.resolve|filepath\.Join|filepath\.Clean|os\.Open|os\.ReadFile|file_get_contents|fopen|File\.open|File\.read|include\s|require\s)`)
27+
rePathNormalization = regexp.MustCompile(`(?i)(?:path\.normalize|path\.resolve|filepath\.Clean|filepath\.Abs|realpath|os\.path\.abspath|os\.path\.normpath|Path\.GetFullPath|Pathname\.cleanpath)`)
28+
)
29+
30+
func init() {
31+
rules.Register(&UTF7XSSBypass{})
32+
rules.Register(&DecodeWithoutNormalization{})
33+
}
34+
35+
// ---------------------------------------------------------------------------
36+
// BATOU-ENC-009: UTF-7 XSS bypass
37+
// ---------------------------------------------------------------------------
38+
39+
type UTF7XSSBypass struct{}
40+
41+
func (r *UTF7XSSBypass) ID() string { return "BATOU-ENC-009" }
42+
func (r *UTF7XSSBypass) Name() string { return "UTF7XSSBypass" }
43+
func (r *UTF7XSSBypass) DefaultSeverity() rules.Severity { return rules.High }
44+
func (r *UTF7XSSBypass) Description() string {
45+
return "Detects Content-Type text/html responses without an explicit charset, allowing UTF-7 XSS attacks in older browsers and certain configurations."
46+
}
47+
func (r *UTF7XSSBypass) Languages() []rules.Language {
48+
return []rules.Language{rules.LangJavaScript, rules.LangTypeScript, rules.LangPython, rules.LangGo, rules.LangJava, rules.LangPHP, rules.LangRuby}
49+
}
50+
51+
func (r *UTF7XSSBypass) Scan(ctx *rules.ScanContext) []rules.Finding {
52+
var findings []rules.Finding
53+
lines := strings.Split(ctx.Content, "\n")
54+
55+
for i, line := range lines {
56+
trimmed := strings.TrimSpace(line)
57+
if isComment(trimmed) {
58+
continue
59+
}
60+
61+
// Check for Content-Type set to text/html
62+
if !reContentTypeSetHTML.MatchString(line) {
63+
continue
64+
}
65+
66+
// Check if charset is specified on this line or nearby
67+
if reCharsetPresent.MatchString(line) {
68+
continue
69+
}
70+
71+
// Look ahead a few lines for charset in the same header setting
72+
hasCharset := false
73+
end := i + 3
74+
if end > len(lines) {
75+
end = len(lines)
76+
}
77+
for j := i; j < end; j++ {
78+
if reCharsetPresent.MatchString(lines[j]) {
79+
hasCharset = true
80+
break
81+
}
82+
}
83+
if hasCharset {
84+
continue
85+
}
86+
87+
// Only flag if there's user output in the file (not just config)
88+
if !reUserOutputNearby.MatchString(ctx.Content) {
89+
continue
90+
}
91+
92+
matched := strings.TrimSpace(line)
93+
if len(matched) > 120 {
94+
matched = matched[:120] + "..."
95+
}
96+
97+
findings = append(findings, rules.Finding{
98+
RuleID: r.ID(),
99+
Severity: r.DefaultSeverity(),
100+
SeverityLabel: r.DefaultSeverity().String(),
101+
Title: "Content-Type text/html without charset (UTF-7 XSS risk)",
102+
Description: "The Content-Type header is set to text/html without specifying a charset. Without an explicit charset=utf-8, some browsers and proxies may auto-detect the encoding, allowing attackers to inject UTF-7 encoded XSS payloads like +ADw-script+AD4- that bypass HTML entity encoding.",
103+
FilePath: ctx.FilePath,
104+
LineNumber: i + 1,
105+
MatchedText: matched,
106+
Suggestion: "Always include charset=utf-8 in Content-Type headers: Content-Type: text/html; charset=utf-8. This prevents encoding-based XSS attacks.",
107+
CWEID: "CWE-116",
108+
OWASPCategory: "A03:2021-Injection",
109+
Language: ctx.Language,
110+
Confidence: "medium",
111+
Tags: []string{"encoding", "utf-7", "xss", "content-type"},
112+
})
113+
}
114+
return findings
115+
}
116+
117+
// ---------------------------------------------------------------------------
118+
// BATOU-ENC-010: URL decode without path normalization
119+
// ---------------------------------------------------------------------------
120+
121+
type DecodeWithoutNormalization struct{}
122+
123+
func (r *DecodeWithoutNormalization) ID() string { return "BATOU-ENC-010" }
124+
func (r *DecodeWithoutNormalization) Name() string { return "DecodeWithoutNormalization" }
125+
func (r *DecodeWithoutNormalization) DefaultSeverity() rules.Severity { return rules.High }
126+
func (r *DecodeWithoutNormalization) Description() string {
127+
return "Detects URL decoding functions (decodeURIComponent, unescape, etc.) near file path operations without path normalization, enabling path traversal via encoded sequences like %2e%2e%2f."
128+
}
129+
func (r *DecodeWithoutNormalization) Languages() []rules.Language {
130+
return []rules.Language{rules.LangJavaScript, rules.LangTypeScript, rules.LangPython, rules.LangGo, rules.LangJava, rules.LangPHP, rules.LangRuby}
131+
}
132+
133+
func (r *DecodeWithoutNormalization) Scan(ctx *rules.ScanContext) []rules.Finding {
134+
var findings []rules.Finding
135+
136+
// Only check files that have both decode and file operations
137+
if !reDecodeURI.MatchString(ctx.Content) {
138+
return nil
139+
}
140+
if !reFilePathOp.MatchString(ctx.Content) {
141+
return nil
142+
}
143+
144+
// If normalization is present in the file, skip
145+
if rePathNormalization.MatchString(ctx.Content) {
146+
return nil
147+
}
148+
149+
lines := strings.Split(ctx.Content, "\n")
150+
for i, line := range lines {
151+
trimmed := strings.TrimSpace(line)
152+
if isComment(trimmed) {
153+
continue
154+
}
155+
156+
if m := reDecodeURI.FindString(line); m != "" {
157+
// Check if file path operations are nearby
158+
window := nearbyLines(lines, i, 10)
159+
if !reFilePathOp.MatchString(window) {
160+
continue
161+
}
162+
163+
matched := strings.TrimSpace(line)
164+
if len(matched) > 120 {
165+
matched = matched[:120] + "..."
166+
}
167+
168+
findings = append(findings, rules.Finding{
169+
RuleID: r.ID(),
170+
Severity: r.DefaultSeverity(),
171+
SeverityLabel: r.DefaultSeverity().String(),
172+
Title: "URL decoding near file operations without path normalization",
173+
Description: "A URL decoding function is used near file path operations without path normalization. Attackers can use percent-encoded path traversal sequences (%2e%2e%2f for ../) or overlong UTF-8 representations to bypass path validation checks that run before decoding.",
174+
FilePath: ctx.FilePath,
175+
LineNumber: i + 1,
176+
MatchedText: matched,
177+
Suggestion: "Always normalize file paths after decoding: use path.resolve() or path.normalize() (Node.js), os.path.abspath() (Python), or filepath.Clean() (Go). Verify the resolved path is within the intended base directory.",
178+
CWEID: "CWE-176",
179+
OWASPCategory: "A01:2021-Broken Access Control",
180+
Language: ctx.Language,
181+
Confidence: "medium",
182+
Tags: []string{"encoding", "path-traversal", "url-decoding", "normalization"},
183+
})
184+
}
185+
}
186+
return findings
187+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package encoding
2+
3+
import (
4+
"testing"
5+
6+
"github.com/turenlabs/batou/internal/testutil"
7+
)
8+
9+
// ---------------------------------------------------------------------------
10+
// BATOU-ENC-009: UTF-7 XSS bypass (Content-Type without charset)
11+
// ---------------------------------------------------------------------------
12+
13+
func TestENC009_ContentTypeNoCharset(t *testing.T) {
14+
content := `app.get('/page', (req, res) => {
15+
res.setHeader('Content-Type', 'text/html');
16+
res.send('<html>' + userData + '</html>');
17+
});`
18+
result := testutil.ScanContent(t, "/app/server.js", content)
19+
testutil.MustFindRule(t, result, "BATOU-ENC-009")
20+
}
21+
22+
func TestENC009_ContentTypeNoCharset_Python(t *testing.T) {
23+
content := `def handler(request):
24+
response = HttpResponse(content)
25+
response.set('Content-Type', 'text/html')
26+
response.write(user_data)
27+
return response`
28+
result := testutil.ScanContent(t, "/app/views.py", content)
29+
testutil.MustFindRule(t, result, "BATOU-ENC-009")
30+
}
31+
32+
func TestENC009_ContentTypeWithCharset_Safe(t *testing.T) {
33+
content := `app.get('/page', (req, res) => {
34+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
35+
res.send('<html>' + data + '</html>');
36+
});`
37+
result := testutil.ScanContent(t, "/app/server.js", content)
38+
testutil.MustNotFindRule(t, result, "BATOU-ENC-009")
39+
}
40+
41+
func TestENC009_ContentTypeCharsetNextLine_Safe(t *testing.T) {
42+
content := `app.get('/page', (req, res) => {
43+
res.set('Content-Type', 'text/html');
44+
res.set('Content-Type', 'text/html; charset=utf-8');
45+
res.send(data);
46+
});`
47+
result := testutil.ScanContent(t, "/app/server.js", content)
48+
testutil.MustNotFindRule(t, result, "BATOU-ENC-009")
49+
}
50+
51+
func TestENC009_NoUserOutput_Safe(t *testing.T) {
52+
// Just config, no output functions
53+
content := `const config = {
54+
contentType: 'text/html'
55+
};`
56+
result := testutil.ScanContent(t, "/app/config.js", content)
57+
testutil.MustNotFindRule(t, result, "BATOU-ENC-009")
58+
}
59+
60+
// ---------------------------------------------------------------------------
61+
// BATOU-ENC-010: URL decode without path normalization
62+
// ---------------------------------------------------------------------------
63+
64+
func TestENC010_DecodeURIWithFileOp(t *testing.T) {
65+
content := `app.get('/files/:name', (req, res) => {
66+
const filename = decodeURIComponent(req.params.name);
67+
const data = fs.readFile('./uploads/' + filename, callback);
68+
res.send(data);
69+
});`
70+
result := testutil.ScanContent(t, "/app/files.js", content)
71+
testutil.MustFindRule(t, result, "BATOU-ENC-010")
72+
}
73+
74+
func TestENC010_UnescapeWithOpen_Python(t *testing.T) {
75+
content := `import urllib
76+
def download(request):
77+
filename = urllib.unquote(request.GET.get('file'))
78+
with open('/uploads/' + filename) as f:
79+
return f.read()`
80+
result := testutil.ScanContent(t, "/app/views.py", content)
81+
testutil.MustFindRule(t, result, "BATOU-ENC-010")
82+
}
83+
84+
func TestENC010_DecodeWithNormalization_Safe(t *testing.T) {
85+
content := `app.get('/files/:name', (req, res) => {
86+
const filename = decodeURIComponent(req.params.name);
87+
const safePath = path.normalize(filename);
88+
const fullPath = path.resolve('./uploads', safePath);
89+
fs.readFile(fullPath, callback);
90+
});`
91+
result := testutil.ScanContent(t, "/app/files.js", content)
92+
testutil.MustNotFindRule(t, result, "BATOU-ENC-010")
93+
}
94+
95+
func TestENC010_DecodeWithClean_Go_Safe(t *testing.T) {
96+
content := `func handler(w http.ResponseWriter, r *http.Request) {
97+
name, _ := url.QueryUnescape(r.URL.Query().Get("file"))
98+
clean := filepath.Clean(name)
99+
data, _ := os.ReadFile(filepath.Join("uploads", clean))
100+
w.Write(data)
101+
}`
102+
result := testutil.ScanContent(t, "/app/handler.go", content)
103+
testutil.MustNotFindRule(t, result, "BATOU-ENC-010")
104+
}
105+
106+
func TestENC010_DecodeNoFileOps_Safe(t *testing.T) {
107+
content := `const decoded = decodeURIComponent(searchQuery);
108+
const results = search(decoded);
109+
res.json(results);`
110+
result := testutil.ScanContent(t, "/app/search.js", content)
111+
testutil.MustNotFindRule(t, result, "BATOU-ENC-010")
112+
}

0 commit comments

Comments
 (0)