← WAF: application protection and operations
11 / 12 · 60 MIN

Origin, proxies and trust

Execute local HTTPS requests to distinguish the connection peer from an X-Forwarded-For claim and design acceptance for a proxy chain.

Execute HTTPS with synthetic data

Save the complete code below as main.go in a new directory. Run go mod init example.test/waf-origin, go get github.com/corazawaf/coraza/v3@v3.8.0 and go run. evidence.json. Recorded execution uses Go 1.27.1. Retain go.mod and go.sum; after obtaining dependencies, you can repeat with GOPROXY=off. Each case creates a loopback HTTPS server, uses the client trusting its test certificate and closes resources. You do not need acquisition.json to execute it. Sent values are invented and the program contacts no external applications.

Compare connection and claim

The client sends X-Forwarded-For containing 203.0.113.77, but the REMOTE_ADDR rule matches the connection’s loopback address. A rule on the literal header value can also match in a separate case. These observations are compatible: the tested wrapper receives the peer and headers as distinct inputs. Pinned source confirms that integration. This lab contains no proxy rewriting the address. In a real system, record what fields mean at each layer before concluding that two different addresses represent an error.

Presence does not validate content

Without the header, comparison with a literal value does not match. A separate count rule rejects absence, but a present empty field passes that control. The exercise separates three questions: does an entry exist, is its content valid and is the claim’s origin trusted? Do not conflate the answers. A syntactically valid IP can still have been supplied by the client. Service policy should define handling of absence, emptiness, invalid format and value lists, with authorized positive and negative examples.

Design the proxy boundary

For a fictional API, draw paths through the gateway and any allowed direct access. Identify who accepts, replaces or appends values and which component chooses the origin used by policy. An address list does not itself provide proof of trust. AWS documentation warns about header modification and absence; this informs analysis but does not turn this fixture into an AWS WAF experiment. For another connector, validate its configuration and behavior. The direct lab only demonstrates input supplied by WrapHandler in this version.

Investigate a migration with rejections

In a fictional positions-query scenario, ingress moves to a shared gateway. Several consumers become associated with the same peer and an IP policy starts rejecting legitimate traffic. Compare the effective key before and after, volumes and topology. Do not raise the threshold without understanding aggregation or immediately accept any header as identity. Propose bounded mitigation, reproduce the effect using authorized synthetic clients and define rollback. The manager coordinates network, APS and security teams to close the decision with evidence.

Prepare origin acceptance

The proposed acceptance matrix includes gateway passage, authorized direct access, an absent field, an empty field and a chain with several hops. For each row, record observed peer, claim, chosen origin, expected outcome and actual outcome. Preserve correlation without collecting real credentials. Local TLS verification demonstrates trust in the test certificate, not header authentication or production PKI acceptance. Give RUN owners, configuration and diagnostic steps. This worksheet is a teaching proposal, with no performed human workshop or production approval.

// Original DR loopback TLS fixture. Harmless values; no external targets or real secrets.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"runtime/debug"
	"strings"
	"time"

	"github.com/corazawaf/coraza/v3"
	corazahttp "github.com/corazawaf/coraza/v3/http"
	"github.com/corazawaf/coraza/v3/types"
)

type Result struct {
	Name string `json:"name"`
	Configuration string `json:"configuration"`
	Status int `json:"status"`
	AppCalls int `json:"applicationCalls"`
	TLSVerified bool `json:"tlsVerified"`
	RuleIDs []int `json:"matchedRuleIds"`
	MatchedValues []string `json:"matchedValues"`
	AuditRecords int `json:"auditRecords"`
	AuditContainsBodyMarker bool `json:"auditContainsBodyMarker"`
	AuditContainsFakeToken bool `json:"auditContainsFakeToken"`
	AuditHasRequestBody bool `json:"auditHasRequestBody"`
	AuditHasRequestHeaders bool `json:"auditHasRequestHeaders"`
	AuditHasMessages bool `json:"auditHasMessages"`
	RawAudit string `json:"rawAudit"`
	ServerClosed bool `json:"serverClosed"`
	WAFClosed bool `json:"wafClosed"`
}

func check(ok bool, message string) {
	if!ok {
		panic(message)
	}
}
func hashFile(name string) string {
	data, err:= os.ReadFile(name)
	if err!= nil {
		panic(err)
	}
	sum:= sha256.Sum256(data)
	return hex.EncodeToString(sum[:])
}

const bodyMarker = "DR-NOTE-ALPHA"
const fakeToken = "DR-FAKE-TOKEN-NOT-A-CREDENTIAL"

func evaluate(root, name, auditMode, parts, rules string, forwarded *string, body string, appStatus int) Result {
	r:= Result{Name: name, RuleIDs: []int{}, MatchedValues: []string{}}
	logPath:= filepath.Join(root, name+".json")
	r.Configuration = "SecRuleEngine On\nSecRequestBodyAccess On\nSecResponseBodyAccess Off\nSecAuditEngine " + auditMode + "\nSecAuditLogType Serial\nSecAuditLogFormat JSON\nSecAuditLogParts " + parts + "\nSecAuditLogRelevantStatus ^5\nSecAuditLog " + logPath + "\n" + rules
	waf, err:= coraza.NewWAF(coraza.NewWAFConfig.WithDirectives(r.Configuration).WithErrorCallback(func(m types.MatchedRule) {
		r.RuleIDs = append(r.RuleIDs, m.Rule.ID)
		for _, d:= range m.MatchedDatas {
			r.MatchedValues = append(r.MatchedValues, d.Value)
		}
	}))
	if err!= nil {
		panic(err)
	}
	closer, ok:= waf.(io.Closer)
	check(ok, "WAF closer available")
	defer func {
		if!r.WAFClosed {
			_ = closer.Close
		}
	}
	app:= http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		r.AppCalls++
		_, err:= io.ReadAll(req.Body)
		if err!= nil {
			panic(err)
		}
		w.Header.Set("Content-Type", "text/plain")
		w.WriteHeader(appStatus)
		_, err = io.WriteString(w, "synthetic-result")
		if err!= nil {
			panic(err)
		}
	})
	server:= httptest.NewTLSServer(corazahttp.WrapHandler(waf, app))
	defer server.Close
	client:= server.Client
	client.Timeout = 5 * time.Second
	transport:= client.Transport.(*http.Transport)
	check(!transport.TLSClientConfig.InsecureSkipVerify, "TLS verification enabled")
	req, err:= http.NewRequest("POST", server.URL+"/submit", strings.NewReader(body))
	if err!= nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Authorization", fakeToken)
	if forwarded!= nil {
		req.Header.Set("X-Forwarded-For", *forwarded)
	}
	response, err:= client.Do(req)
	if err!= nil {
		panic(err)
	}
	_, err = io.ReadAll(response.Body)
	if err!= nil {
		panic(err)
	}
	response.Body.Close
	r.Status = response.StatusCode
	r.TLSVerified = response.TLS!= nil && len(response.TLS.VerifiedChains) > 0
	transport.CloseIdleConnections
	server.Close
	_, err = server.Listener.Accept
	r.ServerClosed = errors.Is(err, net.ErrClosed)
	err = closer.Close
	if err!= nil {
		panic(err)
	}
	r.WAFClosed = true
	data, err:= os.ReadFile(logPath)
	if err!= nil &&!os.IsNotExist(err) {
		panic(err)
	}
	r.RawAudit = string(data)
	r.AuditContainsBodyMarker = strings.Contains(r.RawAudit, bodyMarker)
	r.AuditContainsFakeToken = strings.Contains(r.RawAudit, fakeToken)
	for _, line:= range strings.Split(strings.TrimSpace(r.RawAudit), "\n") {
		if line == "" {
			continue
		}
		var record map[string]any
		if err = json.Unmarshal([]byte(line), &record); err!= nil {
			panic(err)
		}
		r.AuditRecords++
		tx, _:= record["transaction"].(map[string]any)
		request, _:= tx["request"].(map[string]any)
		bodyValue, _:= request["body"].(string)
		r.AuditHasRequestBody = bodyValue!= ""
		headers, _:= request["headers"].(map[string]any)
		r.AuditHasRequestHeaders = len(headers) > 0
		messages, _:= record["messages"].([]any)
		r.AuditHasMessages = len(messages) > 0
	}
	return r
}
func has(r Result, id int) bool {
	for _, n:= range r.RuleIDs {
		if n == id {
			return true
		}
	}
	return false
}
func main {
	check(len(os.Args) == 2, "output path required")
	root, err:= os.MkdirTemp("", "dr-waf-origin-")
	if err!= nil {
		panic(err)
	}
	defer os.RemoveAll(root)
	forwarded:= "203.0.113.77"
	empty:= ""
	peerRule:= `SecRule REMOTE_ADDR "@ipMatch 127.0.0.1,::1" "id:6101,phase:1,t:none,pass,log,noauditlog,msg:'observed loopback peer'"
SecRule REMOTE_ADDR "@ipMatch 203.0.113.77" "id:6102,phase:1,t:none,deny,status:403,log,msg:'configured remote address'"`
	headerRule:= `SecRule REQUEST_HEADERS:X-Forwarded-For "@streq 203.0.113.77" "id:6103,phase:1,t:none,deny,status:403,log,msg:'literal client-supplied header'"`
	presenceRule:= `SecRule &REQUEST_HEADERS:X-Forwarded-For "@eq 0" "id:6104,phase:1,t:none,deny,status:400,log,msg:'required fixture header absent'"`
	auditRule:= `SecRule ARGS:comment "@streq DR-NOTE-ALPHA" "id:6201,phase:2,t:none,pass,log,auditlog,msg:'original audit marker',logdata:'%{MATCHED_VAR}'"`
	noAuditRule:= strings.Replace(auditRule, ",auditlog,", ",noauditlog,", 1)
	cases:= map[string]Result{}
	add:= func(name, mode, parts, rules string, h *string, body string, status int) Result {
		r:= evaluate(root, name, mode, parts, rules, h, body, status)
		cases[name] = r
		check(r.TLSVerified && r.ServerClosed && r.WAFClosed, name+" TLS and cleanup")
		return r
	}
	checks:= map[string]any{}
	r:= add("tls-clean", "Off", "AZ", "", nil, "comment=ordinary", 200)
	check(r.Status == 200 && r.AppCalls == 1, "TLS clean")
	checks["tlsControl"] = map[string]any{"status": r.Status, "verified": r.TLSVerified, "applicationCalls": r.AppCalls}
	r = add("peer-vs-header", "Off", "AZ", peerRule, &forwarded, "comment=ordinary", 200)
	check(r.Status == 200 && has(r, 6101) &&!has(r, 6102), "peer vs forwarded header")
	checks["peerIdentity"] = map[string]any{"loopbackRuleMatched": has(r, 6101), "forwardedValueUsedAsRemoteAddress": has(r, 6102)}
	r = add("literal-header", "Off", "AZ", headerRule, &forwarded, "comment=ordinary", 200)
	check(r.Status == 403 && r.AppCalls == 0 && has(r, 6103), "literal header")
	checks["literalHeaderRule"] = map[string]any{"status": r.Status, "applicationCalls": r.AppCalls, "clientSupplied": true}
	r = add("header-absent", "Off", "AZ", headerRule, nil, "comment=ordinary", 200)
	check(r.Status == 200 &&!has(r, 6103), "header absent")
	checks["absentHeader"] = map[string]any{"status": r.Status, "literalRuleMatched": has(r, 6103)}
	r = add("presence-required", "Off", "AZ", presenceRule, nil, "comment=ordinary", 200)
	check(r.Status == 400 && has(r, 6104) && r.AppCalls == 0, "explicit presence rule")
	checks["explicitPresencePolicy"] = map[string]any{"status": r.Status, "applicationCalls": r.AppCalls}
	r = add("header-empty", "Off", "AZ", presenceRule+"\n"+headerRule, &empty, "comment=ordinary", 200)
	check(r.Status == 200 &&!has(r, 6104) &&!has(r, 6103), "empty differs from absent")
	checks["emptyHeader"] = map[string]any{"status": r.Status, "missingRuleMatched": has(r, 6104), "literalRuleMatched": has(r, 6103), "validIPAddressProven": false}
	r = add("audit-on", "On", "ABCFHZ", "", nil, "comment="+bodyMarker, 200)
	check(r.AuditRecords == 1 && r.AuditHasRequestBody && r.AuditHasRequestHeaders && r.AuditContainsFakeToken, "audit all parts")
	checks["auditAllTransactions"] = map[string]any{"records": r.AuditRecords, "bodyPresent": r.AuditHasRequestBody, "fakeTokenPresent": r.AuditContainsFakeToken}
	clean:= add("relevant-clean", "RelevantOnly", "AHZ", "", nil, "comment=ordinary", 200)
	failure:= add("relevant-503", "RelevantOnly", "AHZ", "", nil, "comment=ordinary", 503)
	check(clean.AuditRecords == 0 && failure.AuditRecords == 1, "relevant status")
	checks["statusSelection"] = map[string]any{"clean200Records": clean.AuditRecords, "application503Records": failure.AuditRecords}
	r = add("match-audit-filtered", "RelevantOnly", "AHZ", auditRule, nil, "comment="+bodyMarker, 200)
	check(r.Status == 200 && r.AuditRecords == 0 && has(r, 6201), "v3.8.0 status filter still applies")
	checks["ruleAuditFiltered"] = map[string]any{"status": r.Status, "records": r.AuditRecords, "matched": has(r, 6201)}
	r = add("match-noaudit-503", "RelevantOnly", "AHZ", noAuditRule, nil, "comment="+bodyMarker, 503)
	check(r.Status == 503 && r.AuditRecords == 1 && has(r, 6201) &&!r.AuditHasMessages, "noaudit does not veto status-selected transaction")
	checks["ruleNoAuditStatus"] = map[string]any{"status": r.Status, "records": r.AuditRecords, "matched": has(r, 6201), "messagesPresent": r.AuditHasMessages}
	r = add("message-data", "On", "AHZ", auditRule, nil, "comment="+bodyMarker, 200)
	check(!r.AuditHasRequestBody &&!r.AuditHasRequestHeaders && r.AuditHasMessages && r.AuditContainsBodyMarker &&!r.AuditContainsFakeToken, "H still contains matched data")
	checks["messageDataRemains"] = map[string]any{"bodyPartPresent": r.AuditHasRequestBody, "headersPartPresent": r.AuditHasRequestHeaders, "messagesPresent": r.AuditHasMessages, "markerPresent": r.AuditContainsBodyMarker}
	r = add("minimal-az", "On", "AZ", auditRule, nil, "comment="+bodyMarker, 200)
	check(r.AuditRecords == 1 &&!r.AuditHasMessages &&!r.AuditContainsBodyMarker &&!r.AuditContainsFakeToken, "AZ reduced evidence")
	checks["minimalRecord"] = map[string]any{"records": r.AuditRecords, "messagesPresent": r.AuditHasMessages, "markerPresent": r.AuditContainsBodyMarker, "fakeTokenPresent": r.AuditContainsFakeToken}
	info, ok:= debug.ReadBuildInfo
	check(ok, "build info")
	version:= ""
	for _, d:= range info.Deps {
		if d.Path == "github.com/corazawaf/coraza/v3" {
			version = d.Version
		}
	}
	check(version == "v3.8.0", "pinned Coraza")
	artifacts:= map[string]string{}
	for _, n:= range []string{"main.go", "go.mod", "go.sum"} {
		artifacts[n] = hashFile(n)
	}
	if _, err = os.Stat("acquisition.json"); err == nil {
		artifacts["acquisition.json"] = hashFile("acquisition.json")
	}
	if err = os.RemoveAll(root); err!= nil {
		panic(err)
	}
	_, err = os.Stat(root)
	removed:= os.IsNotExist(err)
	result:= map[string]any{"checkedAt": time.Now.UTC.Format(time.RFC3339), "engine": "Coraza " + version, "goVersion": runtime.Version, "passed": len(checks), "failed": 0, "checks": checks, "cases": cases, "artifacts": artifacts, "temporaryDirectoryRemoved": removed, "scope": "Actual HTTPS over loopback with Coraza WrapHandler and httptest TLS servers; verified test-server certificate, synthetic headers and JSON audit files. No external targets, trusted-proxy rewriting, identity authentication, full CRS, AWS WAF, attacks, real secrets or production acceptance.", "independentSpecialistReview": false}
	data, err:= json.MarshalIndent(result, "", " ")
	if err!= nil {
		panic(err)
	}
	if err = os.WriteFile(os.Args[1], append(data, '\n'), 0600); err!= nil {
		panic(err)
	}
	fmt.Println("Coraza origin and audit: twelve original HTTPS observation groups passed")
}
IN PRACTICE

After a fictional migration, consumers share the gateway peer. Rehearsal should explain aggregation before changing the key or threshold.

Common pitfalls

Confusing peer with claimed origin, presence with validity and server TLS with header authentication; generalizing the direct wrapper to another connector.

Related topics: WAF architecture and coverage · Rate, origin, and clients · Logs, changes, and RUN handover

Take this idea with you

Choose which origin to use only after demonstrating who supplies it and how each path is handled. Retain the observed peer as distinct evidence.

Create account

Reference: Coraza HTTP middleware source · BigSavant WAF 2026-09; selected AWS WAF and OWASP CRS operational concepts