mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
386 lines
13 KiB
Go
386 lines
13 KiB
Go
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
|
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
|
//
|
|
// SecuBox-Deb :: toolbox-ng :: sbxwaf — request inspection + skip-list tests
|
|
//
|
|
// TDD for Task 2.2: wiring Rules.Match into the handler with CIDR/static/NC
|
|
// skip-lists and body preservation.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// buildSQLiRules writes a minimal waf-rules.json with a UNION SELECT pattern
|
|
// and returns the path. Caller cleanup handled by t.TempDir().
|
|
func buildSQLiRulesFile(t *testing.T) string {
|
|
t.Helper()
|
|
doc := map[string]any{
|
|
"categories": map[string]any{
|
|
"sqli": map[string]any{
|
|
"name": "SQL Injection",
|
|
"severity": "high",
|
|
"enabled": true,
|
|
"patterns": []any{
|
|
map[string]any{"id": "sqli1", "pattern": `union\s+select`, "desc": "UNION SELECT"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
f, err := os.CreateTemp(t.TempDir(), "waf-rules*.json")
|
|
if err != nil {
|
|
t.Fatalf("create temp rules: %v", err)
|
|
}
|
|
if err := json.NewEncoder(f).Encode(doc); err != nil {
|
|
t.Fatalf("encode rules: %v", err)
|
|
}
|
|
f.Close()
|
|
return f.Name()
|
|
}
|
|
|
|
// newInspectServer builds a Server wired with rules and a stub backend.
|
|
// backendURL is the httptest.Server URL the routeLookup will target.
|
|
func newInspectServer(t *testing.T, rulesPath string, backendAddr string) *Server {
|
|
t.Helper()
|
|
srv := &Server{
|
|
routeLookup: func(host string) (ip string, port int, ok bool) {
|
|
h, p, err := splitHostPort(backendAddr)
|
|
if err != nil {
|
|
return "", 0, false
|
|
}
|
|
return h, p, true
|
|
},
|
|
rules: LoadRules(rulesPath),
|
|
}
|
|
return srv
|
|
}
|
|
|
|
// TestInspectBlocksAttack: public IP + UNION SELECT in query → 403.
|
|
func TestInspectBlocksAttack(t *testing.T) {
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "ok")
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
// Public IP, attack query: union+select (URL-encoded, '+' = space after decode)
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.example.com/?q=1+union+select+1,2,3", nil)
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP — no trusted bypass
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for WAF hit from public IP, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestInspectPrivateIPBypass: same attack from private IP → proxied (not 403).
|
|
func TestInspectPrivateIPBypass(t *testing.T) {
|
|
const wantBody = "backend ok"
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, wantBody)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.example.com/?q=1+union+select+1,2,3", nil)
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "192.168.1.50:12345" // private RFC1918 — bypass WAF
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("private IP should bypass WAF inspection, got 403")
|
|
}
|
|
body, _ := io.ReadAll(rec.Result().Body)
|
|
if string(body) != wantBody {
|
|
t.Fatalf("expected backend body %q, got %q", wantBody, string(body))
|
|
}
|
|
}
|
|
|
|
// TestInspectStaticAssetSkip: static asset path with attack query → not blocked.
|
|
func TestInspectStaticAssetSkip(t *testing.T) {
|
|
const wantBody = "js ok"
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, wantBody)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.example.com/app.js?q=1+union+select+1,2", nil)
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP — but .js asset skips inspection
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("static asset should skip WAF inspection, got 403")
|
|
}
|
|
body, _ := io.ReadAll(rec.Result().Body)
|
|
if string(body) != wantBody {
|
|
t.Fatalf("expected backend body %q, got %q", wantBody, string(body))
|
|
}
|
|
}
|
|
|
|
// buildDetectStaticRulesFile writes a rules file with a detect category whose
|
|
// pattern matches a static-asset probe path.
|
|
func buildDetectStaticRulesFile(t *testing.T) string {
|
|
t.Helper()
|
|
f, err := os.CreateTemp(t.TempDir(), "waf-rules*.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = f.WriteString(`{"categories":{"probe":{"mode":"detect","severity":"high",` +
|
|
`"patterns":[{"id":"panos","pattern":"/global-protect/portal/css/bootstrap\\.min\\.css"}]}}}`)
|
|
f.Close()
|
|
return f.Name()
|
|
}
|
|
|
|
// A detect rule on a static-asset path now fires (logged) and the request still
|
|
// passes through — the core of the fix. Before Option B the .css path skipped
|
|
// inspection entirely.
|
|
func TestInspectStaticAssetDetectFires(t *testing.T) {
|
|
const wantBody = "css ok"
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, wantBody)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildDetectStaticRulesFile(t)
|
|
srv := newInspectServer(t, rulesPath, backend.URL[len("http://"):])
|
|
// Route a threat log to a temp file so we can assert the detect record.
|
|
logPath := filepath.Join(t.TempDir(), "threats.log")
|
|
srv.threatLog = NewThreatLog(logPath)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"http://app.example.com/global-protect/portal/css/bootstrap.min.css", nil)
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
// detect = observe: request passes through to backend, NOT blocked.
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("detect must not block; got 403")
|
|
}
|
|
body, _ := io.ReadAll(rec.Result().Body)
|
|
if string(body) != wantBody {
|
|
t.Fatalf("expected backend body %q, got %q", wantBody, string(body))
|
|
}
|
|
// And it was logged as action=detect.
|
|
logged, _ := os.ReadFile(logPath)
|
|
if !strings.Contains(string(logged), `"action":"detect"`) ||
|
|
!strings.Contains(string(logged), `"category":"probe"`) {
|
|
t.Fatalf("expected a detect record for category probe; log=%s", logged)
|
|
}
|
|
}
|
|
|
|
// A legit static asset that matches NO rule still passes with no detect log.
|
|
func TestInspectStaticAssetNoMatchPassthrough(t *testing.T) {
|
|
const wantBody = "css ok"
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, wantBody)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildDetectStaticRulesFile(t)
|
|
srv := newInspectServer(t, rulesPath, backend.URL[len("http://"):])
|
|
logPath := filepath.Join(t.TempDir(), "threats.log")
|
|
srv.threatLog = NewThreatLog(logPath)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"http://app.example.com/static/css/site.min.css", nil) // different path
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("legit static asset should pass; got %d", rec.Code)
|
|
}
|
|
logged, _ := os.ReadFile(logPath)
|
|
if strings.Contains(string(logged), `"action":"detect"`) {
|
|
t.Fatalf("no detect record expected for a non-matching static asset; log=%s", logged)
|
|
}
|
|
}
|
|
|
|
// TestInspectNCBypass: NC mobile auth path with payload → not blocked.
|
|
func TestInspectNCBypass(t *testing.T) {
|
|
const wantBody = "nc login ok"
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, wantBody)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
// NC mobile token path — even with an attack-looking body should not be blocked
|
|
req := httptest.NewRequest(http.MethodPost, "http://app.example.com/index.php/login/v2/", bytes.NewBufferString("data=union+select+1,2"))
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("NC bypass path should not be blocked, got 403")
|
|
}
|
|
body, _ := io.ReadAll(rec.Result().Body)
|
|
if string(body) != wantBody {
|
|
t.Fatalf("expected backend body %q, got %q", wantBody, string(body))
|
|
}
|
|
}
|
|
|
|
// TestInspectLargeBodyForwardedIntact: a POST body larger than defaultMaxBodyInspect
|
|
// (1 MiB + 4 KiB) must arrive at the backend byte-for-byte intact.
|
|
// This is the regression test for the LimitReader truncation bug: the old code
|
|
// restored only the capped prefix to r.Body, silently dropping the tail.
|
|
func TestInspectLargeBodyForwardedIntact(t *testing.T) {
|
|
// Build a benign body of exactly defaultMaxBodyInspect + 4 KiB (no attack pattern).
|
|
// The WAF will inspect the first 1 MiB and pass it; the tail must survive too.
|
|
const extraBytes = 4 * 1024
|
|
bodySize := defaultMaxBodyInspect + extraBytes
|
|
fullBody := make([]byte, bodySize)
|
|
for i := range fullBody {
|
|
fullBody[i] = byte('A' + (i % 26)) // deterministic fill, no attack pattern
|
|
}
|
|
|
|
var receivedBody []byte
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "received")
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodPost, "http://app.example.com/upload", bytes.NewReader(fullBody))
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP — inspection runs
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("benign large POST should not be blocked, got 403")
|
|
}
|
|
if len(receivedBody) != bodySize {
|
|
t.Fatalf("backend received %d bytes, want %d (body was truncated)", len(receivedBody), bodySize)
|
|
}
|
|
if !bytes.Equal(receivedBody, fullBody) {
|
|
// Find first differing byte for a useful diagnostic.
|
|
for i := range fullBody {
|
|
if i >= len(receivedBody) || receivedBody[i] != fullBody[i] {
|
|
t.Fatalf("body mismatch at byte %d: got 0x%02x, want 0x%02x", i,
|
|
func() byte {
|
|
if i < len(receivedBody) {
|
|
return receivedBody[i]
|
|
}
|
|
return 0
|
|
}(), fullBody[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInspectLargeBodyAttackInFirstMiB: attack payload within the first 1 MiB
|
|
// of a large body must still be caught (inspection still works with streaming).
|
|
func TestInspectLargeBodyAttackInFirstMiB(t *testing.T) {
|
|
// 512 KiB of attack prefix + 512 KiB + 4 KiB of padding.
|
|
const extraBytes = 4 * 1024
|
|
bodySize := defaultMaxBodyInspect + extraBytes
|
|
body := make([]byte, bodySize)
|
|
attackSnippet := []byte("union select 1,2,3")
|
|
copy(body[:len(attackSnippet)], attackSnippet)
|
|
// Fill the rest with harmless bytes.
|
|
for i := len(attackSnippet); i < bodySize; i++ {
|
|
body[i] = 'B'
|
|
}
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "ok")
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodPost, "http://app.example.com/upload", bytes.NewReader(body))
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for attack in first 1 MiB of large body, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestInspectBodyForwarded: a POST whose body was read for inspection is still
|
|
// received intact by the backend.
|
|
func TestInspectBodyForwarded(t *testing.T) {
|
|
const postBody = "name=alice&value=harmless"
|
|
var receivedBody []byte
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "received")
|
|
}))
|
|
defer backend.Close()
|
|
|
|
rulesPath := buildSQLiRulesFile(t)
|
|
backendAddr := backend.URL[len("http://"):]
|
|
srv := newInspectServer(t, rulesPath, backendAddr)
|
|
|
|
handler := srv.handler()
|
|
req := httptest.NewRequest(http.MethodPost, "http://app.example.com/submit", bytes.NewBufferString(postBody))
|
|
req.Host = "app.example.com"
|
|
req.RemoteAddr = "1.2.3.4:12345" // public IP — inspection runs but no hit
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("benign POST should not be blocked, got 403")
|
|
}
|
|
if string(receivedBody) != postBody {
|
|
t.Fatalf("backend received body %q, want %q (body not restored after read)", receivedBody, postBody)
|
|
}
|
|
}
|