Skip to content

Commit a7666f3

Browse files
authored
Add G113: Detect HTTP Request Smuggling via conflicting headers (CVE-2025-22891, CWE-444) (#1515)
Implements a new SSA-based analyzer G113 to detect HTTP request smuggling vulnerabilities caused by setting conflicting Transfer-Encoding and Content-Length headers on the same HTTP response. Addresses CVE-2025-22871 where ambiguous HTTP message parsing can lead to request smuggling attacks. When both Transfer-Encoding: chunked and Content-Length headers are set, intermediary proxies and backend servers may disagree on message boundaries, allowing attackers to inject malicious requests. Signed-off-by: Cosmin Cojocar <cosmin@cojocar.ch>
1 parent 47f8b52 commit a7666f3

File tree

6 files changed

+409
-0
lines changed

6 files changed

+409
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ directory you can supply `./...` as the input argument.
191191
- G110: Potential DoS vulnerability via decompression bomb
192192
- G111: Potential directory traversal
193193
- G112: Potential slowloris attack
194+
- G113: HTTP request smuggling via conflicting headers or bare LF in body parsing
194195
- G114: Use of net/http serve function that has no support for setting timeouts
195196
- G115: Potential integer overflow when converting between integer types
196197
- G116: Detect Trojan Source attacks using bidirectional Unicode control characters

analyzers/analyzers_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ var _ = Describe("gosec analyzers", func() {
5151
})
5252

5353
Context("report correct errors for all samples", func() {
54+
It("should detect HTTP request smuggling", func() {
55+
runner("G113", testutils.SampleCodeG113)
56+
})
57+
5458
It("should detect integer conversion overflow", func() {
5559
runner("G115", testutils.SampleCodeG115)
5660
})

analyzers/analyzerslist.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ func NewAnalyzerFilter(action bool, analyzerIDs ...string) AnalyzerFilter {
113113
}
114114

115115
var defaultAnalyzers = []AnalyzerDefinition{
116+
{"G113", "HTTP request smuggling via conflicting headers or bare LF in body parsing", newRequestSmugglingAnalyzer},
116117
{"G115", "Type conversion which leads to integer overflow", newConversionOverflowAnalyzer},
117118
{"G602", "Possible slice bounds out of range", newSliceBoundsAnalyzer},
118119
{"G407", "Use of hardcoded IV/nonce for encryption", newHardCodedNonce},

analyzers/request_smuggling.go

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// (c) Copyright gosec's authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package analyzers
16+
17+
import (
18+
"go/constant"
19+
"go/token"
20+
"go/types"
21+
"strings"
22+
23+
"golang.org/x/tools/go/analysis"
24+
"golang.org/x/tools/go/analysis/passes/buildssa"
25+
"golang.org/x/tools/go/ssa"
26+
27+
"github.com/securego/gosec/v2/internal/ssautil"
28+
"github.com/securego/gosec/v2/issue"
29+
)
30+
31+
const (
32+
msgConflictingHeaders = "Setting both Transfer-Encoding and Content-Length headers may enable request smuggling attacks"
33+
)
34+
35+
// newRequestSmugglingAnalyzer creates an analyzer for detecting HTTP request smuggling
36+
// vulnerabilities (G113) related to CVE-2025-22871 and CWE-444
37+
func newRequestSmugglingAnalyzer(id string, description string) *analysis.Analyzer {
38+
return &analysis.Analyzer{
39+
Name: id,
40+
Doc: description,
41+
Run: runRequestSmugglingAnalysis,
42+
Requires: []*analysis.Analyzer{buildssa.Analyzer},
43+
}
44+
}
45+
46+
// runRequestSmugglingAnalysis performs a single SSA traversal to detect multiple
47+
// HTTP request smuggling patterns for optimal performance
48+
func runRequestSmugglingAnalysis(pass *analysis.Pass) (any, error) {
49+
ssaResult, err := ssautil.GetSSAResult(pass)
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
if len(ssaResult.SSA.SrcFuncs) == 0 {
55+
return nil, nil
56+
}
57+
58+
state := newRequestSmugglingState(pass, ssaResult.SSA.SrcFuncs)
59+
defer state.Release()
60+
61+
var issues []*issue.Issue
62+
63+
// Single traversal to detect all patterns
64+
TraverseSSA(ssaResult.SSA.SrcFuncs, func(b *ssa.BasicBlock, instr ssa.Instruction) {
65+
// Track header operations for conflicts
66+
state.trackHeaderOperation(instr)
67+
})
68+
69+
// Check for header conflicts after traversal
70+
headerIssues := state.detectHeaderConflicts()
71+
issues = append(issues, headerIssues...)
72+
73+
if len(issues) > 0 {
74+
return issues, nil
75+
}
76+
return nil, nil
77+
}
78+
79+
// requestSmugglingState maintains analysis state across the SSA traversal
80+
type requestSmugglingState struct {
81+
*BaseAnalyzerState
82+
ssaFuncs []*ssa.Function
83+
// Track header operations per ResponseWriter to detect conflicts
84+
headerOps map[ssa.Value]*headerTracker
85+
}
86+
87+
// headerTracker records header operations on a specific ResponseWriter instance
88+
type headerTracker struct {
89+
hasTransferEncoding bool
90+
hasContentLength bool
91+
tePos token.Pos
92+
clPos token.Pos
93+
}
94+
95+
func newRequestSmugglingState(pass *analysis.Pass, funcs []*ssa.Function) *requestSmugglingState {
96+
return &requestSmugglingState{
97+
BaseAnalyzerState: NewBaseState(pass),
98+
ssaFuncs: funcs,
99+
headerOps: make(map[ssa.Value]*headerTracker),
100+
}
101+
}
102+
103+
func (s *requestSmugglingState) Release() {
104+
s.headerOps = nil
105+
s.BaseAnalyzerState.Release()
106+
}
107+
108+
// trackHeaderOperation tracks Header().Set() calls on ResponseWriter instances
109+
func (s *requestSmugglingState) trackHeaderOperation(instr ssa.Instruction) {
110+
call, ok := instr.(*ssa.Call)
111+
if !ok {
112+
return
113+
}
114+
115+
// Check if it's a Header().Set() call
116+
callee := call.Call.StaticCallee()
117+
if callee == nil || callee.Name() != "Set" {
118+
return
119+
}
120+
121+
// Check if the receiver is http.Header
122+
if !s.isHTTPHeaderSet(call) {
123+
return
124+
}
125+
126+
// Extract the header key being set
127+
// In SSA, for bound method calls, Args[0] is the receiver (http.Header)
128+
// Args[1] is the key, Args[2] is the value
129+
if len(call.Call.Args) < 3 {
130+
return
131+
}
132+
133+
headerKey := s.extractStringConstant(call.Call.Args[1])
134+
if headerKey == "" {
135+
return
136+
}
137+
138+
// Find the ResponseWriter this header belongs to
139+
writer := s.findResponseWriter(call)
140+
if writer == nil {
141+
return
142+
}
143+
144+
// Track this header operation
145+
if _, exists := s.headerOps[writer]; !exists {
146+
s.headerOps[writer] = &headerTracker{}
147+
}
148+
149+
tracker := s.headerOps[writer]
150+
151+
normalizedKey := strings.ToLower(headerKey)
152+
switch normalizedKey {
153+
case "transfer-encoding":
154+
tracker.hasTransferEncoding = true
155+
tracker.tePos = call.Pos()
156+
case "content-length":
157+
tracker.hasContentLength = true
158+
tracker.clPos = call.Pos()
159+
}
160+
}
161+
162+
// isHTTPHeaderSet checks if a call is to http.Header.Set
163+
func (s *requestSmugglingState) isHTTPHeaderSet(call *ssa.Call) bool {
164+
callee := call.Call.StaticCallee()
165+
if callee == nil {
166+
return false
167+
}
168+
169+
// Check receiver type
170+
if callee.Signature == nil {
171+
return false
172+
}
173+
174+
recv := callee.Signature.Recv()
175+
if recv == nil {
176+
return false
177+
}
178+
179+
recvType := recv.Type()
180+
if recvType == nil {
181+
return false
182+
}
183+
184+
// Check if it's http.Header
185+
namedType, ok := recvType.(*types.Named)
186+
if !ok {
187+
return false
188+
}
189+
190+
obj := namedType.Obj()
191+
if obj == nil || obj.Name() != "Header" {
192+
return false
193+
}
194+
195+
pkg := obj.Pkg()
196+
return pkg != nil && pkg.Path() == "net/http"
197+
}
198+
199+
// extractStringConstant extracts a string value from a constant expression
200+
func (s *requestSmugglingState) extractStringConstant(val ssa.Value) string {
201+
if constVal, ok := val.(*ssa.Const); ok {
202+
if constVal.Value != nil && constVal.Value.Kind() == constant.String {
203+
return constant.StringVal(constVal.Value)
204+
}
205+
}
206+
return ""
207+
}
208+
209+
// findResponseWriter traces back from Header().Set() to find the ResponseWriter
210+
func (s *requestSmugglingState) findResponseWriter(headerSetCall *ssa.Call) ssa.Value {
211+
// The receiver of Set is the Header, which comes from calling Header() on ResponseWriter
212+
if len(headerSetCall.Call.Args) == 0 {
213+
return nil
214+
}
215+
216+
// In SSA, the receiver is the first argument for method calls
217+
receiver := headerSetCall.Call.Args[0]
218+
219+
// Trace back through Header() call
220+
for depth := 0; depth < 5; depth++ {
221+
switch v := receiver.(type) {
222+
case *ssa.Call:
223+
// Check if this is a Header() call
224+
if s.isHeaderMethodCall(v) {
225+
// For invoke (interface method), the receiver is in Call.Value
226+
if v.Call.IsInvoke() {
227+
return v.Call.Value
228+
}
229+
// For static calls, the receiver is in Args[0]
230+
if len(v.Call.Args) > 0 {
231+
return v.Call.Args[0]
232+
}
233+
return nil
234+
}
235+
// Continue tracing
236+
if len(v.Call.Args) > 0 {
237+
receiver = v.Call.Args[0]
238+
} else {
239+
return nil
240+
}
241+
242+
case *ssa.Phi:
243+
// For simplicity, use the first edge
244+
if len(v.Edges) > 0 {
245+
receiver = v.Edges[0]
246+
} else {
247+
return nil
248+
}
249+
250+
case *ssa.Parameter, *ssa.UnOp, *ssa.FieldAddr:
251+
// Found a potential ResponseWriter
252+
return receiver
253+
254+
default:
255+
return nil
256+
}
257+
}
258+
259+
return nil
260+
}
261+
262+
// isHeaderMethodCall checks if a call is to the Header() method of ResponseWriter
263+
func (s *requestSmugglingState) isHeaderMethodCall(call *ssa.Call) bool {
264+
// Check for static calls (concrete types)
265+
callee := call.Call.StaticCallee()
266+
if callee != nil {
267+
return callee.Name() == "Header"
268+
}
269+
270+
// Check for interface method calls (invoke)
271+
if call.Call.IsInvoke() && call.Call.Method != nil {
272+
return call.Call.Method.Name() == "Header"
273+
}
274+
275+
return false
276+
}
277+
278+
// detectHeaderConflicts checks for Transfer-Encoding and Content-Length conflicts
279+
func (s *requestSmugglingState) detectHeaderConflicts() []*issue.Issue {
280+
var issues []*issue.Issue
281+
282+
for _, tracker := range s.headerOps {
283+
if tracker.hasTransferEncoding && tracker.hasContentLength {
284+
// Use the position of the second header set (either could be first)
285+
pos := tracker.clPos
286+
if tracker.tePos > tracker.clPos {
287+
pos = tracker.tePos
288+
}
289+
290+
issue := newIssue(
291+
s.Pass.Analyzer.Name,
292+
msgConflictingHeaders,
293+
s.Pass.Fset,
294+
pos,
295+
issue.High,
296+
issue.High,
297+
)
298+
issues = append(issues, issue)
299+
}
300+
}
301+
302+
return issues
303+
}

cwe/data.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ var idWeaknesses = map[string]*Weakness{
123123
Description: "The software does not handle or incorrectly handles a compressed input with a very high compression ratio that produces a large output.",
124124
Name: "Improper Handling of Highly Compressed Data (Data Amplification)",
125125
},
126+
"444": {
127+
ID: "444",
128+
Description: "When malformed or unexpected HTTP requests are inconsistently interpreted by one or more entities in the data flow between the user and the web server, such as a proxy or firewall, attackers can abuse this discrepancy to smuggle requests to one system without the other system being aware of it.",
129+
Name: "Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling')",
130+
},
126131
"499": {
127132
ID: "499",
128133
Description: "The code contains a class with sensitive data, but the class does not explicitly deny serialization. The data can be accessed by serializing the class through another class.",

0 commit comments

Comments
 (0)