|
1 | 1 | package forwardproxy |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bufio" |
4 | 5 | "context" |
5 | 6 | "io" |
| 7 | + "net" |
6 | 8 | "net/http" |
7 | 9 | "net/http/httptest" |
8 | 10 | "strings" |
@@ -196,5 +198,169 @@ func TestForwardProxy_SkipHosts_NilMatcherPreservesBehavior(t *testing.T) { |
196 | 198 | } |
197 | 199 | } |
198 | 200 |
|
199 | | -// silence unused-import warning if mustParseURL ends up only used here |
200 | | -var _ = strings.TrimSpace |
| 201 | +// TestForwardProxy_SkipHosts_MatchesAgainstHostHeaderNotURL locks the |
| 202 | +// trust-model contract for the HTTP forward path: the skip match keys |
| 203 | +// on the request `Host` header (`r.Host`), not on the dial target |
| 204 | +// derived from `r.URL`. This is what huang195's review called out as |
| 205 | +// the agent-influenceable input — the test exists to make the behavior |
| 206 | +// observable so future maintainers see it when reading the test, and |
| 207 | +// so a refactor that flips this boundary (e.g. switching to r.URL.Host) |
| 208 | +// breaks loudly with a named test. |
| 209 | +// |
| 210 | +// Operators reading this: do NOT add a destination to skip_hosts that |
| 211 | +// you'd want IBAC / token-exchange to deny on. The skip is keyed on |
| 212 | +// agent-influenceable headers; the audit log emitted on each skip is |
| 213 | +// the only after-the-fact signal. |
| 214 | +// |
| 215 | +// The test scopes itself to the trust contract — "skip fires when |
| 216 | +// r.Host matches" — and does NOT assert what happens to the forwarded |
| 217 | +// request afterward. Go's http.Transport behavior with a proxy and a |
| 218 | +// forged Host header is implementation-detail that the trust model |
| 219 | +// shouldn't depend on; what matters is that the skip decision was |
| 220 | +// made on the agent-supplied value. |
| 221 | +func TestForwardProxy_SkipHosts_MatchesAgainstHostHeaderNotURL(t *testing.T) { |
| 222 | + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 223 | + w.WriteHeader(http.StatusOK) |
| 224 | + })) |
| 225 | + defer backend.Close() |
| 226 | + |
| 227 | + store := session.New(5*time.Minute, 100, 0) |
| 228 | + defer store.Close() |
| 229 | + |
| 230 | + // Skip pattern matches a hostname that is NOT the dial target. |
| 231 | + // The dial target is the backend (127.0.0.1:N from backend.URL); |
| 232 | + // the pattern is a fictitious hostname. |
| 233 | + skip, err := skiphost.New([]string{"infrastructure-only.example"}) |
| 234 | + if err != nil { |
| 235 | + t.Fatalf("skiphost.New: %v", err) |
| 236 | + } |
| 237 | + |
| 238 | + proxy, mp := newMarkerServer(t, store, skip) |
| 239 | + defer proxy.Close() |
| 240 | + |
| 241 | + // Send a request whose Host header is the skip-listed pattern |
| 242 | + // while r.URL points at the real backend. A normal Go http.Client |
| 243 | + // would set Host from URL; we override it explicitly to model the |
| 244 | + // adversarial case. |
| 245 | + proxyClient := &http.Client{ |
| 246 | + Transport: &http.Transport{ |
| 247 | + Proxy: http.ProxyURL(mustParseURL(proxy.URL)), |
| 248 | + }, |
| 249 | + } |
| 250 | + req, err := http.NewRequest("GET", backend.URL+"/test", nil) |
| 251 | + if err != nil { |
| 252 | + t.Fatalf("NewRequest: %v", err) |
| 253 | + } |
| 254 | + req.Host = "infrastructure-only.example" // forged Host header |
| 255 | + |
| 256 | + // Don't assert on resp/err — the trust-model claim is solely |
| 257 | + // about the skip decision. Whatever Go's transport ends up doing |
| 258 | + // with the divergent Host vs URL is incidental. |
| 259 | + if resp, err := proxyClient.Do(req); err == nil && resp != nil { |
| 260 | + resp.Body.Close() |
| 261 | + } |
| 262 | + |
| 263 | + // The skip matched against the forged Host, so the pipeline |
| 264 | + // did NOT run. That's the trust-contract assertion: the agent |
| 265 | + // chose the skip-match value via the Host header, not via the |
| 266 | + // real dial target. |
| 267 | + if mp.calls.Load() != 0 { |
| 268 | + t.Errorf("forged Host: pipeline ran %d times, want 0 — skip matched on r.Host (the agent-supplied Host header), not r.URL.Host", mp.calls.Load()) |
| 269 | + } |
| 270 | + if sessions := store.ListSessions(); len(sessions) != 0 { |
| 271 | + t.Errorf("forged Host: %d session(s) recorded, want 0 — skip path bypasses recording", len(sessions)) |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +// TestForwardProxy_SkipHosts_CONNECT_BypassesPipeline asserts that the |
| 276 | +// CONNECT-tunnel path honors SkipHosts. CONNECT is safer-by-construction |
| 277 | +// than the HTTP path because r.Host IS the dial target, but skip |
| 278 | +// behavior must still be exercised so a regression that disables the |
| 279 | +// CONNECT skip path is caught. |
| 280 | +func TestForwardProxy_SkipHosts_CONNECT_BypassesPipeline(t *testing.T) { |
| 281 | + // Minimal echo server — stand-in for an HTTPS upstream we don't |
| 282 | + // want to do TLS to in tests. The skip path opens a tunnel and |
| 283 | + // shuttles bytes; we just need to prove bytes flow. |
| 284 | + upstream, err := net.Listen("tcp", "127.0.0.1:0") |
| 285 | + if err != nil { |
| 286 | + t.Fatalf("listen upstream: %v", err) |
| 287 | + } |
| 288 | + defer upstream.Close() |
| 289 | + go func() { |
| 290 | + conn, err := upstream.Accept() |
| 291 | + if err != nil { |
| 292 | + return |
| 293 | + } |
| 294 | + defer conn.Close() |
| 295 | + buf := make([]byte, 64) |
| 296 | + n, _ := conn.Read(buf) |
| 297 | + _, _ = conn.Write(append([]byte("echo:"), buf[:n]...)) |
| 298 | + }() |
| 299 | + |
| 300 | + upstreamAddr := upstream.Addr().String() |
| 301 | + upstreamHost, _, _ := net.SplitHostPort(upstreamAddr) |
| 302 | + store := session.New(5*time.Minute, 100, 0) |
| 303 | + defer store.Close() |
| 304 | + |
| 305 | + skip, err := skiphost.New([]string{upstreamHost}) |
| 306 | + if err != nil { |
| 307 | + t.Fatalf("skiphost.New: %v", err) |
| 308 | + } |
| 309 | + |
| 310 | + proxy, mp := newMarkerServer(t, store, skip) |
| 311 | + defer proxy.Close() |
| 312 | + |
| 313 | + // Drive CONNECT manually so we can observe the 200 + tunnel. |
| 314 | + proxyAddr := strings.TrimPrefix(proxy.URL, "http://") |
| 315 | + tunnel, err := net.DialTimeout("tcp", proxyAddr, 5*time.Second) |
| 316 | + if err != nil { |
| 317 | + t.Fatalf("dial proxy: %v", err) |
| 318 | + } |
| 319 | + defer tunnel.Close() |
| 320 | + if _, err := tunnel.Write([]byte("CONNECT " + upstreamAddr + " HTTP/1.1\r\nHost: " + upstreamAddr + "\r\n\r\n")); err != nil { |
| 321 | + t.Fatalf("write CONNECT: %v", err) |
| 322 | + } |
| 323 | + |
| 324 | + br := bufio.NewReader(tunnel) |
| 325 | + line, err := br.ReadString('\n') |
| 326 | + if err != nil { |
| 327 | + t.Fatalf("read CONNECT response: %v", err) |
| 328 | + } |
| 329 | + if !strings.Contains(line, "200") { |
| 330 | + t.Fatalf("CONNECT response = %q, want 200 (skip path must still establish the tunnel)", line) |
| 331 | + } |
| 332 | + // Drain headers. |
| 333 | + for { |
| 334 | + hdr, err := br.ReadString('\n') |
| 335 | + if err != nil { |
| 336 | + t.Fatalf("read headers: %v", err) |
| 337 | + } |
| 338 | + if hdr == "\r\n" || hdr == "\n" { |
| 339 | + break |
| 340 | + } |
| 341 | + } |
| 342 | + |
| 343 | + // Tunnel up — write some bytes and confirm the echo round-trips. |
| 344 | + if _, err := tunnel.Write([]byte("hello")); err != nil { |
| 345 | + t.Fatalf("write through tunnel: %v", err) |
| 346 | + } |
| 347 | + got := make([]byte, 32) |
| 348 | + _ = tunnel.SetReadDeadline(time.Now().Add(2 * time.Second)) |
| 349 | + n, err := br.Read(got) |
| 350 | + if err != nil && err != io.EOF { |
| 351 | + t.Fatalf("read tunnel: %v", err) |
| 352 | + } |
| 353 | + if string(got[:n]) != "echo:hello" { |
| 354 | + t.Errorf("tunnel echo = %q, want echo:hello", got[:n]) |
| 355 | + } |
| 356 | + |
| 357 | + // The skipped CONNECT must NOT have run the outbound pipeline |
| 358 | + // and must NOT have appended a session event. |
| 359 | + if mp.calls.Load() != 0 { |
| 360 | + t.Errorf("skipped CONNECT: pipeline ran %d times, want 0", mp.calls.Load()) |
| 361 | + } |
| 362 | + if sessions := store.ListSessions(); len(sessions) != 0 { |
| 363 | + t.Errorf("skipped CONNECT: %d session(s) recorded, want 0", len(sessions)) |
| 364 | + } |
| 365 | +} |
| 366 | + |
0 commit comments