Skip to content

Commit 10ef633

Browse files
authored
Add fuzz testing for JavaScript sanitization functions (#6585)
1 parent 49d8d8f commit 10ef633

7 files changed

Lines changed: 839 additions & 0 deletions

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,9 @@ jobs:
458458
run: |
459459
go test -fuzz=FuzzParseFrontmatter -fuzztime=10s ./pkg/parser/
460460
go test -fuzz=FuzzExpressionParser -fuzztime=10s ./pkg/workflow/
461+
go test -fuzz=FuzzSanitizeOutput -fuzztime=10s ./pkg/workflow/
462+
go test -fuzz=FuzzSanitizeIncomingText -fuzztime=10s ./pkg/workflow/
463+
go test -fuzz=FuzzSanitizeLabelContent -fuzztime=10s ./pkg/workflow/
461464
462465
security:
463466
needs: [lint-go, lint-js] # Run in parallel with test to reduce critical path
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// @ts-check
2+
/**
3+
* Fuzz test harness for sanitize_incoming_text (core sanitization without mention filtering)
4+
* This file is used by Go fuzz tests to test the sanitizeIncomingText function with various inputs.
5+
*/
6+
7+
const { sanitizeIncomingText } = require("./sanitize_incoming_text.cjs");
8+
9+
/**
10+
* Test the sanitizeIncomingText function with given input
11+
* @param {string} text - Input text to sanitize
12+
* @param {number} maxLength - Maximum length of content
13+
* @returns {{sanitized: string, error: string | null}} Result object
14+
*/
15+
function testSanitizeIncomingText(text, maxLength) {
16+
try {
17+
const result = sanitizeIncomingText(text, maxLength);
18+
return { sanitized: result, error: null };
19+
} catch (err) {
20+
return {
21+
sanitized: "",
22+
error: err instanceof Error ? err.message : String(err),
23+
};
24+
}
25+
}
26+
27+
// Read input from stdin for fuzzing
28+
if (require.main === module) {
29+
let input = "";
30+
31+
process.stdin.on("data", chunk => {
32+
input += chunk;
33+
});
34+
35+
process.stdin.on("end", () => {
36+
try {
37+
// Parse input as JSON: { text: string, maxLength: number }
38+
const { text, maxLength } = JSON.parse(input);
39+
const result = testSanitizeIncomingText(text, maxLength);
40+
process.stdout.write(JSON.stringify(result));
41+
process.exit(0);
42+
} catch (err) {
43+
const errorMsg = err instanceof Error ? err.message : String(err);
44+
process.stdout.write(JSON.stringify({ sanitized: "", error: errorMsg }));
45+
process.exit(1);
46+
}
47+
});
48+
}
49+
50+
module.exports = { testSanitizeIncomingText };
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// @ts-check
2+
/**
3+
* Fuzz test harness for sanitize_label_content
4+
* This file is used by Go fuzz tests to test the sanitizeLabelContent function with various inputs.
5+
*/
6+
7+
const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
8+
9+
/**
10+
* Test the sanitizeLabelContent function with given input
11+
* @param {string} text - Input text to sanitize
12+
* @returns {{sanitized: string, error: string | null}} Result object
13+
*/
14+
function testSanitizeLabelContent(text) {
15+
try {
16+
const result = sanitizeLabelContent(text);
17+
return { sanitized: result, error: null };
18+
} catch (err) {
19+
return {
20+
sanitized: "",
21+
error: err instanceof Error ? err.message : String(err),
22+
};
23+
}
24+
}
25+
26+
// Read input from stdin for fuzzing
27+
if (require.main === module) {
28+
let input = "";
29+
30+
process.stdin.on("data", chunk => {
31+
input += chunk;
32+
});
33+
34+
process.stdin.on("end", () => {
35+
try {
36+
// Parse input as JSON: { text: string }
37+
const { text } = JSON.parse(input);
38+
const result = testSanitizeLabelContent(text);
39+
process.stdout.write(JSON.stringify(result));
40+
process.exit(0);
41+
} catch (err) {
42+
const errorMsg = err instanceof Error ? err.message : String(err);
43+
process.stdout.write(JSON.stringify({ sanitized: "", error: errorMsg }));
44+
process.exit(1);
45+
}
46+
});
47+
}
48+
49+
module.exports = { testSanitizeLabelContent };
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// @ts-check
2+
/**
3+
* Fuzz test harness for sanitize_output (sanitizeContent with selective mention filtering)
4+
* This file is used by Go fuzz tests to test the sanitizeContent function with various inputs.
5+
*/
6+
7+
const { sanitizeContent } = require("./sanitize_content.cjs");
8+
9+
/**
10+
* Test the sanitizeContent function with given input
11+
* @param {string} text - Input text to sanitize
12+
* @param {string[]} allowedAliases - List of allowed mention aliases
13+
* @param {number} maxLength - Maximum length of content
14+
* @returns {{sanitized: string, error: string | null}} Result object
15+
*/
16+
function testSanitizeOutput(text, allowedAliases, maxLength) {
17+
try {
18+
const result = sanitizeContent(text, { allowedAliases, maxLength });
19+
return { sanitized: result, error: null };
20+
} catch (err) {
21+
return {
22+
sanitized: "",
23+
error: err instanceof Error ? err.message : String(err),
24+
};
25+
}
26+
}
27+
28+
// Read input from stdin for fuzzing
29+
if (require.main === module) {
30+
let input = "";
31+
32+
process.stdin.on("data", chunk => {
33+
input += chunk;
34+
});
35+
36+
process.stdin.on("end", () => {
37+
try {
38+
// Parse input as JSON: { text: string, allowedAliases: string[], maxLength: number }
39+
const { text, allowedAliases, maxLength } = JSON.parse(input);
40+
const result = testSanitizeOutput(text, allowedAliases || [], maxLength);
41+
process.stdout.write(JSON.stringify(result));
42+
process.exit(0);
43+
} catch (err) {
44+
const errorMsg = err instanceof Error ? err.message : String(err);
45+
process.stdout.write(JSON.stringify({ sanitized: "", error: errorMsg }));
46+
process.exit(1);
47+
}
48+
});
49+
}
50+
51+
module.exports = { testSanitizeOutput };
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package workflow
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// FuzzSanitizeIncomingText performs fuzz testing on the sanitizeIncomingText function
13+
// (used by compute_text.cjs) to validate security controls without selective mention filtering.
14+
//
15+
// This fuzz test uses a hybrid approach: Go's native fuzzing framework generates
16+
// inputs, which are then passed to a JavaScript harness (fuzz_sanitize_incoming_text_harness.cjs)
17+
// via Node.js.
18+
//
19+
// The fuzzer validates that:
20+
// 1. ALL @mentions are neutralized (no selective filtering)
21+
// 2. URL protocols are properly redacted
22+
// 3. Domains outside allowed list are redacted
23+
// 4. XML/HTML tags are properly handled
24+
// 5. Control characters and ANSI codes are removed
25+
// 6. Content length limits are enforced
26+
// 7. Function handles all fuzzer-generated inputs without panic
27+
//
28+
// To run the fuzzer:
29+
//
30+
// go test -v -fuzz=FuzzSanitizeIncomingText -fuzztime=30s ./pkg/workflow
31+
func FuzzSanitizeIncomingText(f *testing.F) {
32+
// Seed corpus with mention patterns (all should be escaped)
33+
f.Add("Hello @user", 0)
34+
f.Add("Hello @user1 and @user2", 0)
35+
f.Add("@org/team mention", 0)
36+
f.Add("Contact @user for help", 0)
37+
f.Add("Multiple @a @b @c mentions", 0)
38+
f.Add("Already `@user` mentioned", 0)
39+
f.Add("Email email@example.com not a mention", 0)
40+
41+
// URL patterns
42+
f.Add("Visit https://github.com/repo", 0)
43+
f.Add("Visit http://example.com", 0)
44+
f.Add("Click javascript:alert('xss')", 0)
45+
f.Add("Mixed: https://github.com http://bad.com", 0)
46+
47+
// Domain filtering
48+
f.Add("https://github.com/path", 0)
49+
f.Add("https://unknown.com/path", 0)
50+
f.Add("https://evil.com?x=https://github.com", 0)
51+
52+
// XML/HTML tags
53+
f.Add("<script>alert('xss')</script>", 0)
54+
f.Add("Safe: <b>bold</b> and <i>italic</i>", 0)
55+
f.Add("<img src='x' onerror='alert(1)'>", 0)
56+
f.Add("<!-- comment -->text", 0)
57+
f.Add("<![CDATA[content]]>", 0)
58+
59+
// Control characters
60+
f.Add("ANSI: \x1b[31mRed\x1b[0m", 0)
61+
f.Add("Null: test\x00text", 0)
62+
f.Add("Control: \x01\x02\x03", 0)
63+
64+
// Commands and bot triggers
65+
f.Add("/bot-command action", 0)
66+
f.Add("fixes #123", 0)
67+
f.Add("closes #456 and resolves #789", 0)
68+
69+
// Length limits
70+
f.Add(strings.Repeat("a", 100), 0)
71+
f.Add(strings.Repeat("a", 1000), 0)
72+
f.Add(strings.Repeat("line\n", 100), 0)
73+
f.Add(strings.Repeat("a", 100), 50) // Short maxLength
74+
f.Add(strings.Repeat("a", 1000), 500) // Custom maxLength
75+
76+
// Combined patterns
77+
f.Add("<script>@user</script>https://evil.com", 0)
78+
f.Add("@user says: javascript:alert(1)", 0)
79+
80+
// Edge cases
81+
f.Add("", 0) // Empty
82+
f.Add(" ", 0) // Whitespace
83+
f.Add("Normal text", 0) // No special chars
84+
f.Add("@", 0) // Just @
85+
f.Add("<>", 0) // Empty tag
86+
f.Add("https://", 0) // Incomplete URL
87+
88+
// Unicode
89+
f.Add("Unicode: 你好 мир 🎉", 0)
90+
f.Add("Emoji: @user 😀", 0)
91+
92+
f.Fuzz(func(t *testing.T, text string, maxLength int) {
93+
// Skip inputs that are too large
94+
if len(text) > 100000 {
95+
t.Skip("Input too large")
96+
}
97+
98+
// Skip negative maxLength
99+
if maxLength < 0 {
100+
t.Skip("Negative maxLength")
101+
}
102+
103+
// Call JavaScript harness via Node.js
104+
result, err := runSanitizeIncomingTextTest(text, maxLength)
105+
106+
// The test should never panic or crash Node.js
107+
if err != nil && !isExpectedError(err) {
108+
t.Errorf("Unexpected error from sanitize incoming text: %v", err)
109+
}
110+
111+
// Basic sanity checks on the result
112+
if result != nil {
113+
// Result should not be excessively longer than input
114+
expectedMaxLen := len(text) + len(text)/2
115+
if maxLength > 0 && maxLength < expectedMaxLen {
116+
expectedMaxLen = maxLength + 100 // Allow for truncation message
117+
}
118+
if len(result.Sanitized) > expectedMaxLen {
119+
t.Errorf("Sanitized result is unexpectedly longer than expected (input: %d, result: %d)",
120+
len(text), len(result.Sanitized))
121+
}
122+
123+
// Verify ALL mentions are neutralized (wrapped in backticks)
124+
// If the original had a bare @mention (not already in backticks),
125+
// it should now be wrapped
126+
if strings.Contains(text, "@") && !strings.Contains(text, "`@") {
127+
// Check that result doesn't have bare mentions
128+
// Pattern: @ followed by alphanumeric (not preceded by backtick)
129+
for i := 0; i < len(result.Sanitized)-1; i++ {
130+
if result.Sanitized[i] == '@' {
131+
// Check if preceded by backtick
132+
if i > 0 && result.Sanitized[i-1] == '`' {
133+
continue // Already wrapped
134+
}
135+
// Check if followed by word character (mention pattern)
136+
if i+1 < len(result.Sanitized) && isWordChar(result.Sanitized[i+1]) {
137+
// This is likely a bare mention that wasn't neutralized
138+
// Allow email patterns (has @ not at word boundary)
139+
if i > 0 && isWordChar(result.Sanitized[i-1]) {
140+
continue // Part of email
141+
}
142+
t.Errorf("Found bare mention in output at position %d: %s", i, result.Sanitized[max(0, i-5):min(len(result.Sanitized), i+10)])
143+
}
144+
}
145+
}
146+
}
147+
148+
// Verify dangerous protocols are removed
149+
// Note: file:/// with three slashes and some data: URLs may not be caught
150+
dangerousProtocols := []string{"javascript:", "vbscript:", "ftp://", "http://"}
151+
for _, proto := range dangerousProtocols {
152+
if strings.Contains(strings.ToLower(result.Sanitized), proto) {
153+
t.Errorf("Dangerous protocol %s not removed from output", proto)
154+
}
155+
}
156+
157+
// Verify control characters are removed
158+
for i, r := range result.Sanitized {
159+
if r < 32 && r != '\n' && r != '\t' {
160+
t.Errorf("Control character %d found at position %d", r, i)
161+
}
162+
if r == 127 {
163+
t.Errorf("DEL character found at position %d", i)
164+
}
165+
}
166+
}
167+
})
168+
}
169+
170+
// Helper function
171+
func isWordChar(b byte) bool {
172+
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
173+
}
174+
175+
// sanitizeIncomingTextTestInput represents the JSON input for the fuzz test harness
176+
type sanitizeIncomingTextTestInput struct {
177+
Text string `json:"text"`
178+
MaxLength int `json:"maxLength"`
179+
}
180+
181+
// sanitizeIncomingTextTestResult represents the JSON output from the fuzz test harness
182+
type sanitizeIncomingTextTestResult struct {
183+
Sanitized string `json:"sanitized"`
184+
Error *string `json:"error"`
185+
}
186+
187+
// runSanitizeIncomingTextTest runs the JavaScript sanitize_incoming_text test harness
188+
func runSanitizeIncomingTextTest(text string, maxLength int) (*sanitizeIncomingTextTestResult, error) {
189+
// Prepare input JSON
190+
input := sanitizeIncomingTextTestInput{
191+
Text: text,
192+
MaxLength: maxLength,
193+
}
194+
inputJSON, err := json.Marshal(input)
195+
if err != nil {
196+
return nil, err
197+
}
198+
199+
// Find the harness file
200+
harnessPath := filepath.Join("js", "fuzz_sanitize_incoming_text_harness.cjs")
201+
202+
// Execute Node.js with the harness
203+
cmd := exec.Command("node", harnessPath)
204+
cmd.Stdin = bytes.NewReader(inputJSON)
205+
206+
var stdout, stderr bytes.Buffer
207+
cmd.Stdout = &stdout
208+
cmd.Stderr = &stderr
209+
210+
err = cmd.Run()
211+
if err != nil {
212+
if stderr.Len() > 0 {
213+
return nil, nil // Expected error
214+
}
215+
return nil, err
216+
}
217+
218+
// Parse output JSON
219+
var result sanitizeIncomingTextTestResult
220+
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
221+
return nil, err
222+
}
223+
224+
return &result, nil
225+
}

0 commit comments

Comments
 (0)