|
| 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 | +} |
0 commit comments