Day 70 — XSS Detection
Learning Objectives
- Detect reflected XSS, script injection, and event handler injection
- Normalize input before matching: HTML-unescape, URL-decode, unicode
- Build the PII scrubber integration (Day 79)
Key Concepts
XSS Attack Vectors:
<script>alert(1)</script> — direct script injection
<img src=x onerror=alert(1)> — event handler injection
javascript:alert(1) — JavaScript URI scheme
<svg onload=alert(1)> — SVG attack
<a href="javascript:void(0)"> — inline JS in href
"><script>alert(1)</script> — breaking out of attribute
Common Bypasses:
<ScRiPt>alert(1)</sCrIpT> — mixed case → normalize to lowercase
%3Cscript%3E — URL encoding → URL decode first
<script> — HTML entity encoding → HTML unescape first
<scr\x00ipt> — null byte injection → remove null bytes
Exercise: XSS Detection Rule
package waf
import (
"html"
"net/http"
"net/url"
"regexp"
"strings"
)
var (
// Script tags in all forms
xssScriptRe = regexp.MustCompile(`(?i)<\s*script[\s>]`)
// JavaScript URI scheme
xssJSURIRe = regexp.MustCompile(`(?i)javascript\s*:`)
// Inline event handlers: onerror=, onload=, onclick=, etc.
xssEventHandlerRe = regexp.MustCompile(`(?i)\bon[a-z]{2,20}\s*=`)
// Dangerous tags
xssDangerousTagsRe = regexp.MustCompile(`(?i)<\s*(iframe|frame|object|embed|applet|meta|link|base|form|input|button|select|textarea)\b`)
// Data URI with script
xssDataURIRe = regexp.MustCompile(`(?i)data:\s*text/html`)
)
type XSSRule struct{}
func (r *XSSRule) ID() string { return "XSS-001" }
func (r *XSSRule) Category() string { return "XSS" }
func (r *XSSRule) Match(req *http.Request, body []byte) (bool, int, string) {
inputs := extractInputs(req, body)
for _, input := range inputs {
normalized := normalizeXSS(input)
if xssScriptRe.MatchString(normalized) {
return true, 8, "XSS: script tag"
}
if xssJSURIRe.MatchString(normalized) {
return true, 7, "XSS: javascript: URI"
}
if xssEventHandlerRe.MatchString(normalized) {
return true, 6, "XSS: event handler injection"
}
if xssDangerousTagsRe.MatchString(normalized) {
return true, 4, "XSS: dangerous HTML tag"
}
if xssDataURIRe.MatchString(normalized) {
return true, 7, "XSS: data: URI with HTML"
}
}
return false, 0, ""
}
// normalizeXSS applies multi-layer decoding before matching
// Order matters: URL decode → HTML unescape → lowercase → strip null bytes
func normalizeXSS(input string) string {
// URL decode (handles %3C → <)
decoded, err := url.QueryUnescape(input)
if err != nil {
decoded = input
}
// HTML unescape (handles < → <)
decoded = html.UnescapeString(decoded)
// Double URL decode (handles %253C → %3C → <)
if again, err := url.QueryUnescape(decoded); err == nil {
decoded = again
}
// Remove null bytes (used to break pattern matching)
decoded = strings.ReplaceAll(decoded, "\x00", "")
// Lowercase for case-insensitive matching
return strings.ToLower(decoded)
}
// Test payloads — all should trigger XSS detection:
var xssTestPayloads = []string{
"<script>alert(1)</script>",
"<ScRiPt>alert(1)</ScRiPt>",
"%3Cscript%3Ealert(1)%3C/script%3E",
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"javascript:alert(1)",
"<svg onload=alert(1)>",
"<iframe src=javascript:alert(1)>",
}
// Legitimate inputs that must NOT trigger:
var xssLegitInputs = []string{
"Hello <World>", // angle bracket in text (no tag)
"2 < 3 is true", // math comparison
"onclick button behavior documentation", // 'onclick' as a word
"<b>bold</b> <i>italic</i>", // common HTML in CMS
"script writing techniques", // 'script' as a word
}
Real-World Context
XSS (OWASP A03) is the most common web vulnerability. The multi-layer normalization is why ModSecurity has 8 separate normalization rules that run before each match. An attacker who knows you check for <script> will try %3Cscript%3E. An attacker who knows you URL-decode will try %253Cscript%253E (double-encoded).