From 8663f36c398de83b3c43569414635e3c5afbce52 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:02:08 +0000 Subject: [PATCH 01/12] Upgrade goproxy to v1.9.0 --- docs/goproxy-v1.9.0-migration.md | 490 ++++++++++ go.mod | 2 +- go.sum | 8 +- internal/cache/handlers.go | 19 + internal/cache/handlers_test.go | 70 ++ proxy_test.go | 75 +- .../github.com/elazarl/goproxy/.golangci.yml | 165 ++++ vendor/github.com/elazarl/goproxy/README.md | 334 +++++-- vendor/github.com/elazarl/goproxy/actions.go | 16 +- vendor/github.com/elazarl/goproxy/certs.go | 29 +- vendor/github.com/elazarl/goproxy/chunked.go | 59 -- vendor/github.com/elazarl/goproxy/ctx.go | 29 +- .../github.com/elazarl/goproxy/dispatcher.go | 98 +- vendor/github.com/elazarl/goproxy/doc.go | 7 +- vendor/github.com/elazarl/goproxy/h2.go | 171 ---- vendor/github.com/elazarl/goproxy/http.go | 138 +++ vendor/github.com/elazarl/goproxy/http2.go | 274 ++++++ vendor/github.com/elazarl/goproxy/https.go | 853 ++++++++++++------ .../goproxy/internal/http1parser/header.go | 43 + .../goproxy/internal/http1parser/request.go | 93 ++ .../{ => internal/signer}/counterecryptor.go | 16 +- .../elazarl/goproxy/internal/signer/signer.go | 122 +++ vendor/github.com/elazarl/goproxy/logger.go | 6 +- vendor/github.com/elazarl/goproxy/proxy.go | 201 ++--- .../github.com/elazarl/goproxy/responses.go | 11 +- vendor/github.com/elazarl/goproxy/signer.go | 108 --- .../github.com/elazarl/goproxy/websocket.go | 111 +-- vendor/modules.txt | 6 +- 28 files changed, 2560 insertions(+), 994 deletions(-) create mode 100644 docs/goproxy-v1.9.0-migration.md create mode 100644 vendor/github.com/elazarl/goproxy/.golangci.yml delete mode 100644 vendor/github.com/elazarl/goproxy/chunked.go delete mode 100644 vendor/github.com/elazarl/goproxy/h2.go create mode 100644 vendor/github.com/elazarl/goproxy/http.go create mode 100644 vendor/github.com/elazarl/goproxy/http2.go create mode 100644 vendor/github.com/elazarl/goproxy/internal/http1parser/header.go create mode 100644 vendor/github.com/elazarl/goproxy/internal/http1parser/request.go rename vendor/github.com/elazarl/goproxy/{ => internal/signer}/counterecryptor.go (79%) create mode 100644 vendor/github.com/elazarl/goproxy/internal/signer/signer.go delete mode 100644 vendor/github.com/elazarl/goproxy/signer.go diff --git a/docs/goproxy-v1.9.0-migration.md b/docs/goproxy-v1.9.0-migration.md new file mode 100644 index 0000000..07fafe7 --- /dev/null +++ b/docs/goproxy-v1.9.0-migration.md @@ -0,0 +1,490 @@ +# goproxy v1.9.0 compatibility audit + +## Scope + +This audit compares the pre-migration goproxy version, +`v0.0.0-20240726154733-8b0c20506380`, with `v1.9.0` +(`6225cd309d7c`). It does not use previous upgrade attempts as evidence. + +The audit covers every production interaction between Dependabot Proxy and +goproxy: + +1. Proxy server creation +2. Outbound connection configuration +3. HTTPS interception +4. MITM CA injection +5. Request handler registration +6. Credential injection +7. `ProxyCtx` state sharing +8. Response handler registration +9. Retry behavior + +## Verdict + +The pre-migration Dependabot Proxy source was API-compatible with goproxy +`v1.9.0`. An isolated copy of that source was changed to require `v1.9.0`, then +`go mod tidy` and `go test ./...` completed successfully without production +code changes. + +That result proves source compatibility and current unit-test compatibility. +It does not fully prove wire compatibility. The largest behavioral change is +the HTTP/1 MITM response writer, and the current test suite does not exercise a +successful HTTPS response through the complete proxy. + +One production adaptation was required at the response-cache boundary. The +cache must not replace `http.NoBody` on responses for which HTTP semantics +forbid a body. The migration implementation now: + +1. Updates `go.mod`, `go.sum`, and `vendor` to goproxy `v1.9.0`. +2. Prevents the cache from wrapping HEAD, non-101 informational, 204, and 304 + responses; preserves the upgraded stream on 101 responses. +3. Adds end-to-end HTTPS MITM framing coverage for GET, HEAD, 204, 304, and a + subsequent response on the same constrained connection pool. +4. Passes the complete test suite twice with race detection and randomized + test order. +5. Keeps `AllowHTTP2` at its default value, `false`, for this upgrade. + +## Go and tooling compatibility + +goproxy modernized its own Go baseline and development tooling between the +pinned commit and `v1.9.0`, but Dependabot Proxy does not need a corresponding +toolchain upgrade: + +| Area | Pinned goproxy | goproxy `v1.9.0` | Dependabot Proxy | Action | +| --- | --- | --- | --- | --- | +| Go directive | `1.18` | `1.24.0` | `1.26.0` | None; Dependabot exceeds the dependency minimum | +| Container compiler | Not imposed on consumers | Requires Go 1.24 or newer | Go `1.26.5` builder | None | +| CI Go selection | Upstream-specific | Upstream-specific | Read from Dependabot's `go.mod` | None | +| Race tests | Upstream test policy | Supported | Docker test runs `-race -count=2` | Retain and run after upgrade | +| Lint configuration | No root config in the pinned commit | Broad golangci-lint v2 config | Smaller repository-specific golangci-lint v2 config, not invoked by CI | Optionally enforce Dependabot's existing config in separate maintenance work | + +The `v1.9.0` module introduces `github.com/coder/websocket` and newer minimum +versions of `golang.org/x/net` and `golang.org/x/text`. Go's minimal version +selection uses Dependabot's already newer `golang.org/x/net` and +`golang.org/x/text` versions. `go mod tidy` and the vendor update must record +the selected dependency graph, including the new websocket package. + +goproxy also modernized internal code by replacing APIs such as `ioutil` with +`io` and `os`, using `any` instead of `interface{}`, using context-aware +`DialContext` and TLS handshake methods, typed atomics, `errors.Is`, +`slices.Contains`, named HTTP status constants, and +`http.NewResponseController`. These changes are internal to goproxy and do not +require Dependabot adapters. Dependabot production code already avoids +`ioutil` and empty-interface declarations, supplies `DialContext`, uses named +HTTP status constants, and builds with a Go version that provides all of these +APIs. + +The vendored goproxy `.golangci.yml` describes upstream's contributor policy; +it is not Dependabot's lint policy. Vendored code remains excluded from +Dependabot's formatting checks. Dependabot CI currently enforces `gofmt`, +`go vet`, `go mod tidy -diff`, builds, and tests, but does not invoke +`golangci-lint` despite the root configuration. Enforcing Dependabot's existing +configuration, or separately evaluating goproxy's additional linters and +`gofumpt`/`gci` formatters, may be useful maintenance work. Neither is a +compatibility requirement for `v1.9.0`, and importing the upstream policy into +this upgrade would create unrelated code churn. + +## Request flow + +```mermaid +sequenceDiagram + participant U as Updater + participant G as goproxy + participant H as Dependabot handlers + participant T as Dependabot transport + participant R as Registry or Git server + + U->>G: HTTP request or HTTPS CONNECT + G->>G: Establish MITM TLS for HTTPS + G->>H: Run request handlers in order + H-->>G: Request or immediate response + G->>T: ProxyCtx.RoundTrip(request) + T->>R: Dial and send request + R-->>G: Response + G->>H: Run response handlers in order + H-->>G: Original or replacement response + G-->>U: Serialize final response +``` + +## Primary behavior change: MITM response framing + +The pinned version writes intercepted HTTPS responses manually. It: + +- writes an `HTTP/1.1` status line directly; +- removes `Content-Length` for every non-HEAD response; +- sets `Transfer-Encoding: chunked` for every non-HEAD response; +- writes chunks using goproxy's custom chunk writer; and +- sets `Connection: close`. + +In `v1.9.0`, goproxy prepares the response and calls: + +```go +resp.Write(&responseHeadWriter{writer: client}) +``` + +Before that call, goproxy: + +- marks a response as chunked when a handler replaced its body or its length is + unknown; +- normalizes the downstream protocol fields to `HTTP/1.1`; and +- removes a stale `Content-Length` when chunking is required. + +Go's `http.Response.Write` now owns the status line, HEAD semantics, +`Content-Length`, chunking, connection-close behavior, body framing, and +trailers. goproxy's `responseHeadWriter` buffers only until the complete header +has been written as one unit, then streams subsequent body writes directly to +the client. + +This is not an API break, but it changes the contract for every +`*http.Response` returned or modified by Dependabot handlers. The final +response must have: + +- a nonzero valid `StatusCode`; +- a non-nil `Header` when a handler intends to mutate headers; +- a `Request` when method-dependent behavior such as HEAD is required; +- a readable, closable `Body`, or `http.NoBody` for a known empty body; and +- consistent `Body`, `ContentLength`, `TransferEncoding`, and trailer fields. + +### Cache incompatibility + +The current cache violates this stricter response contract. Its response +handler wraps every cacheable body with `TeeReadCloser`, including +`http.NoBody`. The resulting `v1.9.0` flow is: + +1. The upstream transport returns a response whose body is `http.NoBody`, such + as a 304 response. +2. The cache replaces that sentinel with `TeeReadCloser`. +3. goproxy observes that a response handler changed the body. +4. goproxy sets unknown-length chunked framing. +5. `http.Response.Write` serializes the response using those fields. +6. Framing bytes for a response that must not have a body remain on the + persistent connection and can be parsed as the next response's status line. + +The cache must bypass body wrapping and storage for: + +- all HEAD responses; +- all informational responses from 100 through 199; +- 204 No Content; and +- 304 Not Modified. + +There is one important exception in cleanup behavior: a 101 Switching +Protocols response carries an upgraded `io.ReadWriter` stream. The cache must +return it unchanged and must not close it. Other body-forbidden responses +should close a non-sentinel original body, set `Body` to `http.NoBody`, clear +`TransferEncoding`, and remove the `Transfer-Encoding` header. + +## Input compatibility audit + +### 1. Proxy server creation + +Current input: + +```go +proxy := goproxy.NewProxyHttpServer() +``` + +`NewProxyHttpServer` retains the same signature and the returned +`*ProxyHttpServer` still implements `http.Handler`. In `v1.9.0` the constructor +also initializes private HTTP/2 server state, so continuing to use the +constructor is correct. + +**Compatibility:** compatible. + +**Required change:** none. + +### 2. Outbound connection configuration + +Dependabot replaces `proxy.Tr` with this `*http.Transport` input: + +```go +&http.Transport{ + Dial: safeDialer.Dial, + DialContext: safeDialer.DialContext, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + Proxy: http.ProxyFromEnvironment, +} +``` + +`ProxyHttpServer.Tr` remains `*http.Transport`. `ProxyCtx.RoundTrip` still uses +`proxy.Tr.RoundTrip`, and goproxy's dial path still prefers `Tr.DialContext` +when no request-specific or CONNECT dialer overrides it. Dependabot supplies a +non-nil `DialContext`, so the safe dialer remains valid. + +The nil `RootCAs` value continues to select the system trust store. Unlike +goproxy's default transport, Dependabot's transport does not set +`InsecureSkipVerify`, so upstream certificates continue to be verified. + +The constructor may initialize `ConnectDial` from `HTTPS_PROXY`. This behavior +exists independently of the `Tr` replacement and is not new in `v1.9.0`. + +**Compatibility:** compatible. + +**Required change:** none. + +**Required test:** verify a resolved blocked IP still produces the expected +result through both HTTP and HTTPS paths after the dependency update. + +### 3. HTTPS interception + +Current input: + +```go +proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm) +``` + +`AlwaysMitm`, `HandleConnect`, `ConnectAction`, and `ConnectMitm` retain their +signatures. For HTTP/1 clients, `v1.9.0` still terminates client TLS, parses the +intercepted request, runs request handlers, sends the upstream request, runs +response handlers, and writes the final response. + +Important internal changes include: + +- request contexts are canceled after each intercepted request; +- origin-form and absolute-form request targets are normalized separately; +- upstream HTTP/2 responses are normalized to downstream HTTP/1.1; +- MITM response heads are coalesced; and +- response bodies are streamed through `http.Response.Write`. + +`AllowHTTP2` is still disabled by default, and the current source does not set +it. The new HTTP/2 MITM implementation is therefore outside the active +Dependabot request path for this upgrade. + +**Compatibility:** API-compatible; wire behavior requires validation. + +**Required change:** no production change. + +**Required tests:** successful HTTPS GET and HEAD, known and unknown body +lengths, empty body, 204, 304, large streamed body, trailers, and cancellation. + +### 4. MITM CA injection + +Dependabot parses the configured certificate and key with `tls.X509KeyPair`, +parses `ca.Leaf`, assigns `goproxy.GoproxyCa`, rebuilds the predefined CONNECT +actions with `TLSConfigFromCA`, and supplies `proxy.CertStore`. + +In `v1.9.0`: + +- `GoproxyCa` remains a `tls.Certificate`; +- `TLSConfigFromCA` retains its signature; +- `ConnectAction.TLSConfig` retains its signature; +- `CertStorage.Fetch` retains its signature; and +- the signer accepts RSA, ECDSA, and Ed25519 CA private keys. + +The current `certStore.Fetch` serializes access with a mutex and returns the +generated certificate for the normalized hostname. This satisfies the +`v1.9.0` interface and avoids duplicate concurrent generation. + +`HTTPMitmConnect` is deprecated in `v1.9.0` but still present. Assigning it is +source-compatible, and the active `AlwaysMitm` path uses `MitmConnect`. + +**Compatibility:** compatible. + +**Required change:** none. Removing the unused deprecated +`HTTPMitmConnect` assignment can be handled separately and is not required for +this upgrade. + +**Required test:** trust the configured CA, complete an HTTPS request, and +verify repeated requests reuse a valid host certificate. + +### 5. Request handler registration + +Dependabot registers handlers with `OnRequest().DoFunc`. The callback remains: + +```go +func(*http.Request, *goproxy.ProxyCtx) (*http.Request, *http.Response) +``` + +`v1.9.0` still executes request handlers in registration order and stops at +the first non-nil response. Current handlers return either the mutable request +and `nil`, or the request and a complete immediate response. These are valid +inputs. + +**Compatibility:** compatible. + +**Required change:** none. + +**Required test:** assert order and short-circuit behavior through the running +proxy, not only by invoking handlers directly. + +### 6. Credential injection + +Credential handlers mutate the supplied request using standard headers such as +`Authorization`, `X-GitHub-PSI-JWT`, and registry-specific headers. They return +the same request to goproxy. + +`v1.9.0` removes hop-by-hop proxy headers before forwarding, but it does not +remove ordinary end-server `Authorization` headers. The mutable request and +header contract is unchanged, so current credential inputs remain valid. + +**Compatibility:** compatible. + +**Required change:** none. + +**Required tests:** use two TLS upstream servers to prove that matching +credentials arrive at the intended server and never arrive at an unmatched +server. + +### 7. `ProxyCtx` state sharing + +Dependabot stores `map[string]any` in `ProxyCtx.UserData` through +`internal/proxyctx`. `UserData` changed from `interface{}` to its alias `any`, +which is source-compatible. For the active HTTP/1 path, the same `ProxyCtx` +still reaches the request and response handlers. + +`RoundTripper`, `Req`, `Resp`, `Error`, `Session`, and `Proxy` remain +available. `v1.9.0` adds a request-specific `Dialer`; Dependabot does not set +it. + +When HTTP/2 MITM is enabled, goproxy copies the CONNECT parent's `UserData` +into per-stream contexts. Dependabot's map is not concurrency-safe. This does +not affect the current upgrade because `AllowHTTP2` remains false, but it must +be addressed before enabling HTTP/2. + +**Compatibility:** compatible with the active HTTP/1 configuration. + +**Required change:** none for this upgrade. + +**Required tests:** request-to-response visibility and isolation between +separate HTTP/1 requests. Run with `-race`. + +### 8. Response handler registration and response inputs + +Dependabot registers handlers with `OnResponse().DoFunc`. The callback remains: + +```go +func(*http.Response, *goproxy.ProxyCtx) *http.Response +``` + +`v1.9.0` still executes all response handlers in registration order and sets +`ProxyCtx.Resp` before each call. + +Dependabot supplies three response shapes: + +| Source | Current fields | `v1.9.0` result | +| --- | --- | --- | +| Upstream `http.Transport` | Complete standard-library response | Compatible | +| `goproxy.NewResponse` in security handlers | Request, status, header, length, body | Compatible; `v1.9.0` also initializes HTTP/1.1 protocol fields | +| Disk-cache hit | Request, status code, saved headers, file body | Accepted; goproxy normalizes protocol and uses chunked or close-delimited framing when length metadata is incomplete | + +The logger and cache may replace or wrap `resp.Body`. `v1.9.0` detects a body +identity change, deletes stale `Content-Length`, and selects chunked framing. +That is compatible with the current wrappers and is a behavior that must be +tested on the wire. + +The cache does not restore `Status`, protocol fields, or `ContentLength`. +`v1.9.0` derives the status text, normalizes the protocol, and safely handles +the unknown length. Restoring `ContentLength` would improve keep-alive framing +but is not required for correctness or for this dependency upgrade. + +Response handlers must continue returning a non-nil response with a non-nil +body on the successful MITM path. Current production handlers satisfy that +contract. + +**Compatibility:** upstream responses, generated responses, and body wrappers +are compatible except for the cache wrapping body-forbidden responses. + +**Required change:** update `internal/cache.DB.OnResponse` to bypass caching for +HEAD, 1xx, 204, and 304 responses. Preserve the original body for 101; normalize +the other body-forbidden responses to `http.NoBody` and clear transfer encoding. + +**Required tests:** generated 403 response, cache hit, logged 401/403 body, +handler-wrapped body, trailers, body-forbidden statuses, 101 upgraded-stream +preservation, and response-handler order through HTTPS MITM. + +### 9. Retry behavior + +GitHub and Git handlers clone `ProxyCtx.Req`, change authentication, and call: + +```go +proxyCtx.RoundTrip(newReq) +``` + +Docker authentication installs a custom implementation of goproxy's +`RoundTripper` interface. Both contracts are unchanged: + +```go +type RoundTripper interface { + RoundTrip(*http.Request, *ProxyCtx) (*http.Response, error) +} +``` + +`ProxyCtx.RoundTrip` still delegates to the custom round tripper when present, +otherwise to `ProxyHttpServer.Tr`. Retry calls occur inside the active request +context before `v1.9.0` cancels that context. Current retry requests and +replacement responses are therefore valid. + +Dependabot, not goproxy, continues to own retry eligibility, alternate +credential selection, and draining discarded response bodies. + +**Compatibility:** compatible. + +**Required change:** none. + +**Required tests:** first credential fails and second succeeds, all credentials +fail, retry round trip returns an error, POST body replay, Docker custom round +tripper, and discarded-body closure through the running HTTPS proxy. + +## Required implementation work + +### Production files + +Update the response cache in addition to dependency metadata and vendored +goproxy code: + +- `go.mod` +- `go.sum` +- `internal/cache/handlers.go` +- `internal/cache/handlers_test.go` +- `vendor/modules.txt` +- `vendor/github.com/elazarl/goproxy/**` + +Do not enable `ProxyHttpServer.AllowHTTP2` as part of this upgrade. + +### Tests + +Add integration coverage that starts: + +1. a TLS upstream server; +2. the complete Dependabot proxy with its configured CA; and +3. an HTTP client that trusts that CA and connects through the proxy. + +The test matrix must cover: + +| Area | Cases | +| --- | --- | +| Framing | GET, HEAD, empty, fixed length, unknown length, 204, 304, trailers, large stream | +| Immediate responses | metadata-host 403 and blocked-IP behavior | +| Handler bodies | logger replay and cache tee wrapper | +| Cache | first upstream response, subsequent cache hit, HEAD, 1xx, 204, 304, and 101 upgraded-stream preservation | +| Credentials | matching injection and unmatched isolation | +| Context | request/response state visibility and request isolation | +| Retries | alternate auth success/failure and replayed request body | +| Certificates | configured CA trust and certificate-store reuse | + +After adding focused tests, run: + +```bash +go test ./... +go test -race -count=2 ./... +``` + +## Migration checklist + +- [x] Compare all production goproxy APIs used by the current source. +- [x] Inspect the old and new internal HTTP/MITM control flow. +- [x] Validate current inputs passed to goproxy. +- [x] Compile and run current tests unchanged against `v1.9.0` in isolation. +- [x] Identify the cache/body-framing incompatibility. +- [x] Prevent caching or wrapping body-forbidden responses while preserving 101 upgraded streams. +- [x] Add initial end-to-end HTTPS MITM framing tests for GET, HEAD, 204, 304, and connection reuse. +- [ ] Extend HTTPS MITM framing tests to empty and unknown-length bodies, trailers, large streams, and cancellation. +- [ ] Add cache, credential, context, and retry integration tests. +- [x] Update the dependency and vendor directory. +- [x] Run the full suite with race detection. + +## Upstream references + +- [`v1.9.0` source](https://github.com/elazarl/goproxy/tree/v1.9.0) +- [`8b0c20506380...v1.9.0` comparison](https://github.com/elazarl/goproxy/compare/8b0c20506380...v1.9.0) \ No newline at end of file diff --git a/go.mod b/go.mod index 219c96a..1f633a2 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.36 github.com/aws/aws-sdk-go-v2/credentials v1.19.35 github.com/aws/aws-sdk-go-v2/service/ecr v1.60.5 - github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 + github.com/elazarl/goproxy v1.9.0 github.com/evalphobia/logrus_sentry v0.8.2 github.com/getsentry/raven-go v0.2.0 github.com/jarcoal/httpmock v1.4.2 diff --git a/go.sum b/go.sum index bc94d58..de95948 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/cenk/backoff v2.2.1+incompatible h1:djdFT7f4gF2ttuzRKPbMOWgZajgesItGL github.com/cenk/backoff v2.2.1+incompatible/go.mod h1:7FtoeaSnHoZnmZzz47cM35Y9nSW7tNyaidugnHTaFDE= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40 h1:xvUo53O5MRZhVMJAxWCJcS5HHrqAiAG9SJ1LpMu6aAI= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -41,10 +43,8 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= -github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 h1:1NyRx2f4W4WBRyg0Kys0ZbaNmDDzZ2R/C7DTi+bbsJ0= -github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380/go.mod h1:thX175TtLTzLj3p7N/Q9IiKZ7NF+p72cvL91emV0hzo= -github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= -github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/elazarl/goproxy v1.9.0 h1:2j3c13lD5v0QTjxphJSSIHS7w8/m/pzSHtLMPOpznC0= +github.com/elazarl/goproxy v1.9.0/go.mod h1:THdE5ix2clxX9lZzcICPpZ67d6CdrPZxdOYsNgU5e30= github.com/evalphobia/logrus_sentry v0.8.2 h1:dotxHq+YLZsT1Bb45bB5UQbfCh3gM/nFFetyN46VoDQ= github.com/evalphobia/logrus_sentry v0.8.2/go.mod h1:pKcp+vriitUqu9KiWj/VRFbRfFNUwz95/UkgG8a6MNc= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= diff --git a/internal/cache/handlers.go b/internal/cache/handlers.go index f090579..7d64bfa 100644 --- a/internal/cache/handlers.go +++ b/internal/cache/handlers.go @@ -214,6 +214,18 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R logrus.Warnln("Received nil response") return resp } + if resp.StatusCode == http.StatusSwitchingProtocols { + return resp + } + if responseMustNotHaveBody(resp) { + if resp.Body != nil && resp.Body != http.NoBody { + _ = resp.Body.Close() + } + resp.Body = http.NoBody + resp.TransferEncoding = nil + resp.Header.Del("Transfer-Encoding") + return resp + } k, ok := proxyctx.GetValue(proxyCtx, keyValue) if !ok { // can't calculate key as response body is empty @@ -259,6 +271,13 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R return resp } +func responseMustNotHaveBody(resp *http.Response) bool { + return resp.StatusCode >= 100 && resp.StatusCode < 200 || + resp.StatusCode == http.StatusNoContent || + resp.StatusCode == http.StatusNotModified || + resp.Request != nil && resp.Request.Method == http.MethodHead +} + var sanitizeRegex = regexp.MustCompile(`\W`) func sanitize(host string) string { diff --git a/internal/cache/handlers_test.go b/internal/cache/handlers_test.go index 0c4a08c..a3e8d86 100644 --- a/internal/cache/handlers_test.go +++ b/internal/cache/handlers_test.go @@ -18,6 +18,8 @@ import ( "github.com/elazarl/goproxy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dependabot/proxy/internal/proxyctx" ) // None of these tests should make network calls @@ -111,6 +113,74 @@ func TestCache(t *testing.T) { }) } +func TestCache_BodyForbiddenResponses(t *testing.T) { + tests := []struct { + name string + method string + statusCode int + }{ + {name: "informational", method: http.MethodGet, statusCode: http.StatusEarlyHints}, + {name: "HEAD", method: http.MethodHead, statusCode: http.StatusOK}, + {name: "no content", method: http.MethodGet, statusCode: http.StatusNoContent}, + {name: "not modified", method: http.MethodGet, statusCode: http.StatusNotModified}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cacher, err := New(true, t.TempDir()) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), test.method, URL, nil) + proxyCtx := &goproxy.ProxyCtx{Req: req} + proxyctx.SetValue(proxyCtx, keyValue, Key{Method: req.Method, URL: req.URL.String()}) + + originalBody := &BufferWithClose{} + resp := &http.Response{ + Request: req, + StatusCode: test.statusCode, + Header: http.Header{"Transfer-Encoding": []string{"chunked"}}, + Body: originalBody, + TransferEncoding: []string{"chunked"}, + } + + result := cacher.OnResponse(resp, proxyCtx) + + assert.Same(t, resp, result) + assert.Equal(t, http.NoBody, result.Body) + assert.True(t, originalBody.WasCloseCalled) + assert.Empty(t, result.TransferEncoding) + assert.Empty(t, result.Header.Values("Transfer-Encoding")) + assert.Empty(t, cacher.cacheDB) + assert.Zero(t, cacher.callCursor) + }) + } +} + +func TestCache_SwitchingProtocolsPreservesUpgradedStream(t *testing.T) { + cacher, err := New(true, t.TempDir()) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, URL, nil) + proxyCtx := &goproxy.ProxyCtx{Req: req} + proxyctx.SetValue(proxyCtx, keyValue, Key{Method: req.Method, URL: req.URL.String()}) + + upgradedStream := &BufferWithClose{} + resp := &http.Response{ + Request: req, + StatusCode: http.StatusSwitchingProtocols, + Header: http.Header{"Upgrade": []string{"websocket"}}, + Body: upgradedStream, + } + + result := cacher.OnResponse(resp, proxyCtx) + + assert.Same(t, resp, result) + assert.Same(t, upgradedStream, result.Body) + assert.False(t, upgradedStream.WasCloseCalled) + assert.Empty(t, cacher.cacheDB) + assert.Zero(t, cacher.callCursor) +} + func Test_sanitize(t *testing.T) { var tests = []struct { Input, Expected string diff --git a/proxy_test.go b/proxy_test.go index 22b29f2..5c38d03 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -9,9 +9,11 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "io" "math/big" "net" "net/http" + "net/http/httptest" "net/url" "testing" "time" @@ -46,6 +48,64 @@ func TestProxyHTTPRequest(t *testing.T) { assert.Equal(t, 200, rsp.StatusCode) } +func TestProxyHTTPSMITMResponseFraming(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/fixed": + _, err := io.WriteString(w, "hello") + assert.NoError(t, err) + case "/no-content": + w.WriteHeader(http.StatusNoContent) + case "/not-modified": + w.WriteHeader(http.StatusNotModified) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + client, proxy := testProxyServer(t, testProxyConfig, nil, upstream.Certificate()) + defer proxy.Close() + + tests := []struct { + name string + method string + path string + statusCode int + body string + }{ + {name: "fixed length", method: http.MethodGet, path: "/fixed", statusCode: http.StatusOK, body: "hello"}, + {name: "HEAD", method: http.MethodHead, path: "/fixed", statusCode: http.StatusOK}, + {name: "no content", method: http.MethodGet, path: "/no-content", statusCode: http.StatusNoContent}, + {name: "not modified", method: http.MethodGet, path: "/not-modified", statusCode: http.StatusNotModified}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req, err := http.NewRequestWithContext(t.Context(), test.method, upstream.URL+test.path, nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + assert.Equal(t, test.statusCode, resp.StatusCode) + assert.Equal(t, test.body, string(body)) + }) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL+"/fixed", nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "hello", string(body)) +} + func TestIPRestrictions(t *testing.T) { blockedIPs = []net.IP{iPV4Localhost, iPV6Localhost} client, proxy := testProxyServer(t, testProxyConfig, blockedIPs) @@ -149,7 +209,7 @@ func TestMetadataAPIRestriction(t *testing.T) { } } -func testProxyServer(t *testing.T, cfg *config.Config, blockedIPs []net.IP) (*http.Client, *http.Server) { +func testProxyServer(t *testing.T, cfg *config.Config, blockedIPs []net.IP, upstreamRoots ...*x509.Certificate) (*http.Client, *http.Server) { envSettings := config.ProxyEnvSettings{ APIEndpoint: "", PackageManager: "", @@ -162,7 +222,18 @@ func testProxyServer(t *testing.T, cfg *config.Config, blockedIPs []net.IP) (*ht srv := &http.Server{ ReadHeaderTimeout: 10 * time.Second, } - srv.Handler = newProxy(envSettings, testProxyConfig, blockedIPs) + proxyHandler := newProxy(envSettings, cfg, blockedIPs) + if len(upstreamRoots) > 0 { + rootCAs, err := x509.SystemCertPool() + if err != nil { + rootCAs = x509.NewCertPool() + } + for _, certificate := range upstreamRoots { + rootCAs.AddCert(certificate) + } + proxyHandler.Tr.TLSClientConfig.RootCAs = rootCAs + } + srv.Handler = proxyHandler lc := net.ListenConfig{} ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") diff --git a/vendor/github.com/elazarl/goproxy/.golangci.yml b/vendor/github.com/elazarl/goproxy/.golangci.yml new file mode 100644 index 0000000..b6af05e --- /dev/null +++ b/vendor/github.com/elazarl/goproxy/.golangci.yml @@ -0,0 +1,165 @@ +version: "2" +run: + modules-download-mode: readonly + +# List from https://golangci-lint.run/usage/linters/ +linters: + enable: + - asasalint + - asciicheck + - bidichk + - containedctx + - decorder + - dogsled + - durationcheck + - errchkjson + - errname + - errorlint + - fatcontext + - forbidigo + - forcetypeassert + - gocheckcompilerdirectives + - gochecksumtype + - gocritic + - godot + - goheader + - gomodguard_v2 + - goprintffuncname + - gosec + - gosmopolitan + - grouper + - iface + - importas + - interfacebloat + - lll + - loggercheck + - makezero + - mirror + - misspell + - nakedret + - nilerr + - noctx + - nolintlint + - perfsprint + - prealloc + - predeclared + - reassign + - revive + - staticcheck + - tagalign + - testableexamples + - testifylint + - testpackage + - thelper + - tparallel + - unconvert + - usestdlibvars + - wastedassign + - whitespace + disable: + - bodyclose + - canonicalheader + - contextcheck # Re-enable in V2 + - copyloopvar + - cyclop + - depguard + - dupl + - dupword + - err113 + - exhaustive + - exhaustruct + - funlen + - ginkgolinter + - gochecknoglobals + - gochecknoinits + - gocognit + - goconst + - gocyclo + - godox + - gomoddirectives + - inamedparam + - intrange + - ireturn + - maintidx + - mnd + - musttag + - nestif # TODO: Re-enable in V2 + - nilnil + - nlreturn + - nonamedreturns + - nosprintfhostport + - paralleltest + - promlinter + - protogetter + - rowserrcheck + - sloglint + - spancheck + - sqlclosecheck + - tagliatelle + - unparam + - varnamelen + - wrapcheck + - wsl + - zerologlint + settings: + gosec: + excludes: + - G402 # InsecureSkipVerify + - G102 # Binds to all network interfaces + - G403 # RSA keys should be at least 2048 bits + - G115 # Integer overflow conversion (uint64 -> int64) + - G404 # Use of weak random number generator (math/rand) + - G204 # Subprocess launched with a potential tainted input or cmd arguments + - G602 # Slice index out of range + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - gocritic + text: ifElseChain + - linters: + - lll + source: '^// ' + - linters: + - revive + text: 'add-constant: ' + - linters: + - revive + text: 'unused-parameter: ' + - linters: + - revive + text: 'empty-block: ' + - linters: + - revive + text: 'var-naming: ' # TODO: Re-enable in V2 + - linters: + - staticcheck + text: ' should be ' # TODO: Re-enable in V2 + - linters: + - staticcheck + text: 'ST1003: should not use ALL_CAPS in Go names; use CamelCase instead' + paths: + - examples$ + - transport +formatters: + enable: + - gci + - gofmt + - gofumpt + settings: + gci: + sections: + - standard + - default + custom-order: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/vendor/github.com/elazarl/goproxy/README.md b/vendor/github.com/elazarl/goproxy/README.md index 495afc2..b6c7208 100644 --- a/vendor/github.com/elazarl/goproxy/README.md +++ b/vendor/github.com/elazarl/goproxy/README.md @@ -1,57 +1,131 @@ -# Introduction +# GoProxy -[![GoDoc](https://godoc.org/github.com/elazarl/goproxy?status.svg)](https://godoc.org/github.com/elazarl/goproxy) -[![Join the chat at https://gitter.im/elazarl/goproxy](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/elazarl/goproxy?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ![Status](https://github.com/elazarl/goproxy/workflows/Go/badge.svg) +[![GoDoc](https://pkg.go.dev/badge/github.com/elazarl/goproxy)](https://pkg.go.dev/github.com/elazarl/goproxy) +[![Go Report](https://goreportcard.com/badge/github.com/elazarl/goproxy)](https://goreportcard.com/report/github.com/elazarl/goproxy) +[![BSD-3 License](https://img.shields.io/badge/License-BSD%203--Clause-orange.svg)](https://opensource.org/licenses/BSD-3-Clause) +[![Pull Requests](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) +[![Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go?tab=readme-ov-file#networking) + +GoProxy is a library to create a `customized` HTTP/HTTPS `proxy server` using +Go (aka Golang), with several configurable settings available. +The target of this project is to offer an `optimized` proxy server, usable with +reasonable amount of traffic, yet `customizable` and `programmable`. + +The proxy itself is simply a `net/http` handler, so you can add multiple +middlewares (panic recover, logging, compression, etc.) over it. It can be +easily integrated with any other HTTP network library. + +In order to use goproxy, one should set their browser (or any other client) +to use goproxy as an HTTP proxy. +Here is how you do that in [Chrome](https://www.wikihow.com/Connect-to-a-Proxy-Server) +and in [Firefox](http://www.wikihow.com/Enter-Proxy-Settings-in-Firefox). +If you decide to start with the `base` example, the URL you should use as +proxy is `localhost:8080`, which is the default one in our example. +You also have to [trust](https://github.com/elazarl/goproxy/blob/master/examples/customca/README.md) +the proxy CA certificate, to avoid any certificate issue in the clients. + +> [✈️ Telegram Group](https://telegram.me/goproxygroup) +> +> [🎁 Become a Sponsor](https://opencollective.com/goproxy) + +## Features +- Perform certain actions only on `specific hosts`, with a single equality comparison or with regex evaluation +- Manipulate `requests` and `responses` before sending them to the browser +- Use a `custom http.Transport` to perform requests to the target server +- You can specify a `MITM certificates cache`, to reuse them later for other requests to the same host, thus saving CPU. Not enabled by default, but you should use it in production! +- Redirect normal HTTP traffic to a `custom handler`, when the target is a `relative path` (e.g. `/ping`) +- You can choose the logger to use, by implementing the `Logger` interface +- You can `disable` the HTTP request headers `canonicalization`, by setting `PreventCanonicalization` to true + +## Proxy modes +1. Regular HTTP proxy +2. HTTPS through CONNECT +3. HTTPS MITM ("Man in the Middle") proxy server, in which the server generate TLS certificates to parse request/response data and perform actions on them +4. "Hijacked" proxy connection, where the configured handler can access the raw net.Conn data + +## Sponsors +Does your company use GoProxy? Help us keep the project maintained and healthy! +Supporting GoProxy allows us to dedicate more time to bug fixes and new features. +In exchange, if you choose a Gold Supporter or Enterprise plan, we'll proudly display your company logo here. + +> [Become a Sponsor](https://opencollective.com/goproxy) + +[![Gold Supporters](https://opencollective.com/goproxy/tiers/gold-sponsor.svg?width=890)](https://opencollective.com/goproxy) +[![Enterprise Supporters](https://opencollective.com/goproxy/tiers/enterprise.svg?width=890)](https://opencollective.com/goproxy) + +## Maintainers +- [Elazar Leibovich](https://github.com/elazarl): Creator of the project, Software Engineer +- [Erik Pellizzon](https://github.com/ErikPelli): Maintainer, Freelancer (open to collaborations!) + +If you need to integrate GoProxy into your project, or you need some custom +features to maintain in your fork, you can contact [Erik](mailto:erikpelli@tutamail.com) +(the current maintainer) by email, and you can discuss together how he +can help you as a paid independent consultant. + +## Contributions +If you have any trouble, suggestion, or if you find a bug, feel free to reach +out by opening a GitHub `issue`. +This is an `open source` project managed by volunteers, and we're happy +to discuss anything that can improve it. + +Make sure to explain everything, including the reason behind the issue +and what you want to change, to make the problem easier to understand. +You can also directly open a `Pull Request`, if it's a small code change, but +you need to explain in the description everything. +If you open a pull request named `refactoring` with `5,000` lines changed, +we won't merge it... `:D` + +The code for this project is released under the `BSD 3-Clause` license, +making it useful for `commercial` uses as well. + +### Submit your case study +So, you have introduced & integrated GoProxy into one of your personal projects +or a project inside the company you work for. + +We're happy to learn about new `creative solutions` made with this library, +so feel free to `contact` the maintainer listed above via e-mail, to explaining +why you found this project useful for your needs. + +If you have signed a `Non Disclosure Agreement` with the company, you +can propose them to write a `blog post` on their official website about +this topic, so this information will be public by their choice, and you can +`share the link` of the blog post with us :) + +The purpose of case studies is to share with the `community` why all the +`contributors` to this project are `improving` the world with their help and +what people are building using it. + +### Linter +The codebase uses an automatic lint check over your Pull Request code. +Before opening it, you should check if your changes respect it, by running +the linter in your local machine, so you won't have any surprise. + +To install the linter: +```sh +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest +``` -Package goproxy provides a customizable HTTP proxy library for Go (golang), - -It supports regular HTTP proxy, HTTPS through CONNECT, and "hijacking" HTTPS -connection using "Man in the Middle" style attack. - -The intent of the proxy is to be usable with reasonable amount of traffic, -yet customizable and programmable. - -The proxy itself is simply a `net/http` handler. - -In order to use goproxy, one should set their browser to use goproxy as an HTTP -proxy. Here is how you do that [in Chrome](https://support.google.com/chrome/answer/96815?hl=en) -and [in Firefox](http://www.wikihow.com/Enter-Proxy-Settings-in-Firefox). - -For example, the URL you should use as proxy when running `./bin/basic` is -`localhost:8080`, as this is the default binding for the basic proxy. - -## Mailing List - -New features will be discussed on the [mailing list](https://groups.google.com/forum/#!forum/goproxy-dev) -before their development. - -## Latest Stable Release - -Get the latest goproxy from `gopkg.in/elazarl/goproxy.v1`. - -# Why not Fiddler2? - -Fiddler is an excellent software with similar intent. However, Fiddler is not -as customizable as goproxy intends to be. The main difference is, Fiddler is not -intended to be used as a real proxy. - -A possible use case that suits goproxy but -not Fiddler, is gathering statistics on page load times for a certain website over a week. -With goproxy you could ask all your users to set their proxy to a dedicated machine running a -goproxy server. Fiddler is a GUI app not designed to be run like a server for multiple users. +This will create an executable in your `$GOPATH/bin` folder +(`$GOPATH` is an environment variable, usually +its value is equivalent to `~/go`, check its value in your machine if you +aren't sure about it). +Make sure to include the bin folder in the path of your shell, to be able to +directly use the `golangci-lint run` command. -# A taste of goproxy +## A taste of GoProxy -To get a taste of `goproxy`, a basic HTTP/HTTPS transparent proxy +To get a taste of `goproxy`, here you are a basic HTTP/HTTPS proxy +that just forward data to the destination: ```go package main import ( - "github.com/elazarl/goproxy" "log" "net/http" + + "github.com/elazarl/goproxy" ) func main() { @@ -61,7 +135,9 @@ func main() { } ``` -This line will add `X-GoProxy: yxorPoG-X` header to all requests sent through the proxy +### Request handler +This line will add `X-GoProxy: yxorPoG-X` header to all requests sent through the proxy, +before sending them to the destination: ```go proxy.OnRequest().DoFunc( @@ -71,99 +147,157 @@ proxy.OnRequest().DoFunc( }) ``` -`DoFunc` will process all incoming requests to the proxy. It will add a header to the request -and return it. The proxy will send the modified request. +When the `OnRequest()` input is empty, the function specified in `DoFunc` +will process all incoming requests to the proxy. In this case, it will add +a header to the request and return it to the caller. +The proxy will send the modified request to the destination. +You can also use `Do` instead of `DoFunc`, if you implement the specified +interface in your type. -Note that we returned nil value as the response. Had we returned a response, goproxy would -have discarded the request and sent the new response to the client. +> ⚠️ Note we returned a nil value as the response. +> If the returned response is not nil, goproxy will discard the request +> and send the specified response to the client. -In order to refuse connections to reddit at work time +### Conditional Request handler +Refuse connections to www.reddit.com between 8 and 17 in the server +local timezone: ```go proxy.OnRequest(goproxy.DstHostIs("www.reddit.com")).DoFunc( - func(r *http.Request,ctx *goproxy.ProxyCtx)(*http.Request,*http.Response) { + func(req *http.Request,ctx *goproxy.ProxyCtx)(*http.Request,*http.Response) { if h,_,_ := time.Now().Clock(); h >= 8 && h <= 17 { - return r,goproxy.NewResponse(r, - goproxy.ContentTypeText,http.StatusForbidden, - "Don't waste your time!") + resp := goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusForbidden, "Don't waste your time!") + return req, resp } - return r,nil + return req, nil }) ``` -`DstHostIs` returns a `ReqCondition`, that is a function receiving a `Request` and returning a boolean. -We will only process requests that match the condition. `DstHostIs("www.reddit.com")` will return -a `ReqCondition` accepting only requests directed to "www.reddit.com". - -`DoFunc` will receive a function that will preprocess the request. We can change the request, or -return a response. If the time is between 8:00am and 17:00pm, we will reject the request, and -return a pre-canned text response saying "do not waste your time". - -See additional examples in the examples directory. +`DstHostIs` returns a `ReqCondition`, which is a function receiving a `*http.Request` +and returning a boolean that checks if the request satisfies the condition (and that will be processed). +`DstHostIs("www.reddit.com")` will return a `ReqCondition` that returns true +when the request is directed to "www.reddit.com". +The host equality check is `case-insensitive`, to reflect the behaviour of DNS +resolvers, so even if the user types "www.rEdDit.com", the comparison will +satisfy the condition. +When the hour is between 8:00am and 5:59pm, we directly return +a response in `DoFunc()`, so the remote destination will not receive the +request and the client will receive the `"Don't waste your time!"` response. + +### Let's start +```go +import "github.com/elazarl/goproxy" +``` +There are some proxy usage examples in the `examples` folder, which +cover the most common cases. Take a look at them and good luck! -# Type of handlers for manipulating connect/req/resp behavior +## Request & Response manipulation -There are 3 kinds of useful handlers to manipulate the behavior, as follows: +There are 3 different types of handlers to manipulate the behavior of the proxy, as follows: ```go -// handler called after receiving HTTP CONNECT from the client, and before proxy establish connection -// with destination host +// handler called after receiving HTTP CONNECT from the client, and +// before proxy establishes connection with the destination host httpsHandlers []HttpsHandler - -// handler called before proxy send HTTP request to destination host + +// handler called before proxy sends HTTP request to destination host reqHandlers []ReqHandler - -// handler called after proxy receives HTTP Response from destination host, and before proxy forward -// the Response to the client. + +// handler called after proxy receives HTTP Response from destination host, +// and before proxy forwards the Response to the client respHandlers []RespHandler ``` -Depending on what you want to manipulate, the ways to add handlers to each handler list are: +Depending on what you want to manipulate, the ways to add handlers to each of the previous lists are: ```go // Add handlers to httpsHandlers -proxy.OnRequest(Some ReqConditions).HandleConnect(YourHandlerFunc()) +proxy.OnRequest(some ReqConditions).HandleConnect(YourHandlerFunc()) // Add handlers to reqHandlers -proxy.OnRequest(Some ReqConditions).Do(YourReqHandlerFunc()) +proxy.OnRequest(some ReqConditions).Do(YourReqHandlerFunc()) // Add handlers to respHandlers -proxy.OnResponse(Some RespConditions).Do(YourRespHandlerFunc()) +proxy.OnResponse(some RespConditions).Do(YourRespHandlerFunc()) ``` -For example: +Example: ```go -// This rejects the HTTPS request to *.reddit.com during HTTP CONNECT phase -proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("reddit.*:443$"))).HandleConnect(goproxy.AlwaysReject) - -// This will NOT reject the HTTPS request with URL ending with gif, due to the fact that proxy -// only got the URL.Hostname and URL.Port during the HTTP CONNECT phase if the scheme is HTTPS, which is -// quiet common these days. +// This rejects the HTTPS request to *.reddit.com during HTTP CONNECT phase. +// Reddit URL check is case-insensitive because of (?i), so the block will work also if the user types something like rEdDit.com. +proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("(?i)reddit.*:443$"))).HandleConnect(goproxy.AlwaysReject) + +// Be careful about this example! It shows you a common error that you +// need to avoid. +// This will NOT reject the HTTPS request with URL ending with .gif because, +// if the scheme is HTTPS, the proxy will receive only URL.Hostname +// and URL.Port during the HTTP CONNECT phase. proxy.OnRequest(goproxy.UrlMatches(regexp.MustCompile(`.*gif$`))).HandleConnect(goproxy.AlwaysReject) -// The correct way to manipulate the HTTP request using URL.Path as condition is: +// To fix the previous example, here there is the correct way to manipulate +// an HTTP request using URL.Path (target path) as a condition. proxy.OnRequest(goproxy.UrlMatches(regexp.MustCompile(`.*gif$`))).Do(YourReqHandlerFunc()) ``` -# What's New - -1. Ability to `Hijack` CONNECT requests. See -[the eavesdropper example](https://github.com/elazarl/goproxy/blob/master/examples/goproxy-eavesdropper/main.go#L27) -2. Transparent proxy support for http/https including MITM certificate generation for TLS. See the [transparent example.](https://github.com/elazarl/goproxy/tree/master/examples/goproxy-transparent) - -# License - -I put the software temporarily under the Go-compatible BSD license. -If this prevents someone from using the software, do let me know and I'll consider changing it. +## Error handling +### Generic error +If an error occurs while handling a request through the proxy, by default +the proxy returns HTTP error `500` (Internal Server Error) with the `error +message` as the `body` content. -At any rate, user feedback is very important for me, so I'll be delighted to know if you're using this package. +If you want to override this behaviour, you can define your own +`RespHandler` that changes the error response. +Among the context parameters, `ctx.Error` contains the `error` occurred, +if any, or the `nil` value, if no error happened. -# Beta Software - -I've received positive feedback from a few people who use goproxy in production settings. -I believe it is good enough for usage. +You can handle it as you wish, including returning a custom JSON as the body. +Example of an error handler: +``` +proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { + var dnsError *net.DNSError + if errors.As(ctx.Error, &dnsError) { + // Do not leak our DNS server's address + dnsError.Server = "" + return goproxy.NewResponse(ctx.Req, goproxy.ContentTypeText, http.StatusBadGateway, dnsError.Error()) + } + return resp +}) +``` -I'll try to keep reasonable backwards compatibility. In case of a major API change, -I'll change the import path. +### Connection error +If an error occurs while sending data to the target remote server (or to +the proxy client), the `proxy.ConnectionErrHandler` is called to handle the +error, if present, else a `default handler` will be used. +The error is passed as `function parameter` and not inside the proxy context, +so you don't have to check the ctx.Error field in this handler. + +In this handler you have access to the raw connection with the proxy +client (as an `io.Writer`), so you could send any HTTP data over it, +if needed, containing the error data. +There is no guarantee that the connection hasn't already been closed, so +the `Write()` could return an error. + +The `connection` will be `automatically closed` by the proxy library after the +error handler call, so you don't have to worry about it. + +## Project Status +This project has been created `10 years` ago, and has reached a stage of +`maturity`. It can be safely used in `production`, and many projects +already do that. + +If there will be any `breaking change` in the future, a `new version` of the +Go module will be released (e.g. v2). + +## Trusted, as a direct dependency, by: +

+ Stripe + Dependabot + Go Git + Google + Grafana + Fly.io + Kubernetes / Minikube + New Relic +

diff --git a/vendor/github.com/elazarl/goproxy/actions.go b/vendor/github.com/elazarl/goproxy/actions.go index e1a3e7f..94eb90c 100644 --- a/vendor/github.com/elazarl/goproxy/actions.go +++ b/vendor/github.com/elazarl/goproxy/actions.go @@ -11,10 +11,10 @@ type ReqHandler interface { Handle(req *http.Request, ctx *ProxyCtx) (*http.Request, *http.Response) } -// A wrapper that would convert a function to a ReqHandler interface type +// A wrapper that would convert a function to a ReqHandler interface type. type FuncReqHandler func(req *http.Request, ctx *ProxyCtx) (*http.Request, *http.Response) -// FuncReqHandler.Handle(req,ctx) <=> FuncReqHandler(req,ctx) +// FuncReqHandler.Handle(req,ctx) <=> FuncReqHandler(req,ctx). func (f FuncReqHandler) Handle(req *http.Request, ctx *ProxyCtx) (*http.Request, *http.Response) { return f(req, ctx) } @@ -22,15 +22,15 @@ func (f FuncReqHandler) Handle(req *http.Request, ctx *ProxyCtx) (*http.Request, // after the proxy have sent the request to the destination server, it will // "filter" the response through the RespHandlers it has. // The proxy server will send to the client the response returned by the RespHandler. -// In case of error, resp will be nil, and ctx.RoundTrip.Error will contain the error +// In case of error, resp will be nil, and ctx.RoundTrip.Error will contain the error. type RespHandler interface { Handle(resp *http.Response, ctx *ProxyCtx) *http.Response } -// A wrapper that would convert a function to a RespHandler interface type +// A wrapper that would convert a function to a RespHandler interface type. type FuncRespHandler func(resp *http.Response, ctx *ProxyCtx) *http.Response -// FuncRespHandler.Handle(req,ctx) <=> FuncRespHandler(req,ctx) +// FuncRespHandler.Handle(req,ctx) <=> FuncRespHandler(req,ctx). func (f FuncRespHandler) Handle(resp *http.Response, ctx *ProxyCtx) *http.Response { return f(resp, ctx) } @@ -43,15 +43,15 @@ func (f FuncRespHandler) Handle(resp *http.Response, ctx *ProxyCtx) *http.Respon // send back and forth all messages from the server to the client and vice versa. // The request and responses sent in this Man In the Middle channel are filtered // through the usual flow (request and response filtered through the ReqHandlers -// and RespHandlers) +// and RespHandlers). type HttpsHandler interface { HandleConnect(req string, ctx *ProxyCtx) (*ConnectAction, string) } -// A wrapper that would convert a function to a HttpsHandler interface type +// A wrapper that would convert a function to a HttpsHandler interface type. type FuncHttpsHandler func(host string, ctx *ProxyCtx) (*ConnectAction, string) -// FuncHttpsHandler should implement the RespHandler interface +// FuncHttpsHandler should implement the RespHandler interface. func (f FuncHttpsHandler) HandleConnect(host string, ctx *ProxyCtx) (*ConnectAction, string) { return f(host, ctx) } diff --git a/vendor/github.com/elazarl/goproxy/certs.go b/vendor/github.com/elazarl/goproxy/certs.go index 4731971..b1a2076 100644 --- a/vendor/github.com/elazarl/goproxy/certs.go +++ b/vendor/github.com/elazarl/goproxy/certs.go @@ -2,25 +2,36 @@ package goproxy import ( "crypto/tls" - "crypto/x509" ) +// GoproxyCa is the built-in self-signed CA certificate used by default for MITM interception. +// It is loaded at package initialization from CA_CERT and CA_KEY. +// You can replace it with your own CA by calling TLSConfigFromCA with a different certificate +// and assigning the result to ConnectAction.TLSConfig. +var GoproxyCa tls.Certificate + func init() { - if goproxyCaErr != nil { - panic("Error parsing builtin CA " + goproxyCaErr.Error()) - } + // When we included the embedded certificate inside this file, we made + // sure that it was valid. + // If there is an error here, this is a really exceptional case that requires + // a panic. It should NEVER happen! var err error - if GoproxyCa.Leaf, err = x509.ParseCertificate(GoproxyCa.Certificate[0]); err != nil { - panic("Error parsing builtin CA " + err.Error()) + GoproxyCa, err = tls.X509KeyPair(CA_CERT, CA_KEY) + if err != nil { + panic("Error parsing builtin CA: " + err.Error()) } } -var tlsClientSkipVerify = &tls.Config{InsecureSkipVerify: true} +var tlsClientSkipVerify = &tls.Config{} var defaultTLSConfig = &tls.Config{ InsecureSkipVerify: true, } +// CA_CERT is the PEM-encoded certificate of the built-in proxy CA. +// It is used together with CA_KEY to initialize GoproxyCa at startup. +// Expose it to clients so they can import and trust the proxy CA, +// which is required to avoid TLS errors during MITM interception. var CA_CERT = []byte(`-----BEGIN CERTIFICATE----- MIIF9DCCA9ygAwIBAgIJAODqYUwoVjJkMA0GCSqGSIb3DQEBCwUAMIGOMQswCQYD VQQGEwJJTDEPMA0GA1UECAwGQ2VudGVyMQwwCgYDVQQHDANMb2QxEDAOBgNVBAoM @@ -56,6 +67,8 @@ NCNwK5Yl6HuvF97CIH5CdgO+5C7KifUtqTOL8pQKbNwy0S3sNYvB+njGvRpR7pKV BUnFpB/Atptqr4CUlTXrc5IPLAqAfmwk5IKcwy3EXUbruf9Dwz69YA== -----END CERTIFICATE-----`) +// CA_KEY is the PEM-encoded RSA private key of the built-in proxy CA. +// It is used together with CA_CERT to initialize GoproxyCa at startup. var CA_KEY = []byte(`-----BEGIN RSA PRIVATE KEY----- MIIJKAIBAAKCAgEAnhDL4fqGGhjWzRBFy8iHGuNIdo79FtoWPevCpyek6AWrTuBF 0j3dzRMUpAkemC/p94tGES9f9iWUVi7gnfmUz1lxhjiqUoW5K1xfwmbx+qmC2YAw @@ -107,5 +120,3 @@ pmcjjocD/UCCSuHgbAYNNnO/JdhnSylz1tIg26I+2iLNyeTKIepSNlsBxnkLmqM1 cj/azKBaT04IOMLaN8xfSqitJYSraWMVNgGJM5vfcVaivZnNh0lZBv+qu6YkdM88 4/avCJ8IutT+FcMM+GbGazOm5ALWqUyhrnbLGc4CQMPfe7Il6NxwcrOxT8w= -----END RSA PRIVATE KEY-----`) - -var GoproxyCa, goproxyCaErr = tls.X509KeyPair(CA_CERT, CA_KEY) diff --git a/vendor/github.com/elazarl/goproxy/chunked.go b/vendor/github.com/elazarl/goproxy/chunked.go deleted file mode 100644 index 83654f6..0000000 --- a/vendor/github.com/elazarl/goproxy/chunked.go +++ /dev/null @@ -1,59 +0,0 @@ -// Taken from $GOROOT/src/pkg/net/http/chunked -// needed to write https responses to client. -package goproxy - -import ( - "io" - "strconv" -) - -// newChunkedWriter returns a new chunkedWriter that translates writes into HTTP -// "chunked" format before writing them to w. Closing the returned chunkedWriter -// sends the final 0-length chunk that marks the end of the stream. -// -// newChunkedWriter is not needed by normal applications. The http -// package adds chunking automatically if handlers don't set a -// Content-Length header. Using newChunkedWriter inside a handler -// would result in double chunking or chunking with a Content-Length -// length, both of which are wrong. -func newChunkedWriter(w io.Writer) io.WriteCloser { - return &chunkedWriter{w} -} - -// Writing to chunkedWriter translates to writing in HTTP chunked Transfer -// Encoding wire format to the underlying Wire chunkedWriter. -type chunkedWriter struct { - Wire io.Writer -} - -// Write the contents of data as one chunk to Wire. -// NOTE: Note that the corresponding chunk-writing procedure in Conn.Write has -// a bug since it does not check for success of io.WriteString -func (cw *chunkedWriter) Write(data []byte) (n int, err error) { - - // Don't send 0-length data. It looks like EOF for chunked encoding. - if len(data) == 0 { - return 0, nil - } - - head := strconv.FormatInt(int64(len(data)), 16) + "\r\n" - - if _, err = io.WriteString(cw.Wire, head); err != nil { - return 0, err - } - if n, err = cw.Wire.Write(data); err != nil { - return - } - if n != len(data) { - err = io.ErrShortWrite - return - } - _, err = io.WriteString(cw.Wire, "\r\n") - - return -} - -func (cw *chunkedWriter) Close() error { - _, err := io.WriteString(cw.Wire, "0\r\n") - return err -} diff --git a/vendor/github.com/elazarl/goproxy/ctx.go b/vendor/github.com/elazarl/goproxy/ctx.go index b372f7d..4180d14 100644 --- a/vendor/github.com/elazarl/goproxy/ctx.go +++ b/vendor/github.com/elazarl/goproxy/ctx.go @@ -1,9 +1,11 @@ package goproxy import ( + "context" "crypto/tls" + "mime" + "net" "net/http" - "regexp" ) // ProxyCtx is the Proxy context, contains useful information about every request. It is passed to @@ -14,11 +16,14 @@ type ProxyCtx struct { // Will contain the remote server's response (if available. nil if the request wasn't send yet) Resp *http.Response RoundTripper RoundTripper + // Specify a custom connection dialer that will be used only for the current + // request, including WebSocket connection upgrades + Dialer func(ctx context.Context, network string, addr string) (net.Conn, error) // will contain the recent error that occurred while trying to send receive or parse traffic Error error // A handle for the user to keep data in the context, from the call of ReqHandler to the // call of RespHandler - UserData interface{} + UserData any // Will connect a request to a response Session int64 certStore CertStorage @@ -46,8 +51,8 @@ func (ctx *ProxyCtx) RoundTrip(req *http.Request) (*http.Response, error) { return ctx.Proxy.Tr.RoundTrip(req) } -func (ctx *ProxyCtx) printf(msg string, argv ...interface{}) { - ctx.Proxy.Logger.Printf("[%03d] "+msg+"\n", append([]interface{}{ctx.Session & 0xFF}, argv...)...) +func (ctx *ProxyCtx) printf(msg string, argv ...any) { + ctx.Proxy.Logger.Printf("[%03d] "+msg+"\n", append([]any{ctx.Session & 0xFFFF}, argv...)...) } // Logf prints a message to the proxy's log. Should be used in a ProxyHttpServer's filter @@ -58,7 +63,7 @@ func (ctx *ProxyCtx) printf(msg string, argv ...interface{}) { // ctx.Printf("So far %d requests",nr) // return r, nil // }) -func (ctx *ProxyCtx) Logf(msg string, argv ...interface{}) { +func (ctx *ProxyCtx) Logf(msg string, argv ...any) { if ctx.Proxy.Verbose { ctx.printf("INFO: "+msg, argv...) } @@ -75,19 +80,19 @@ func (ctx *ProxyCtx) Logf(msg string, argv ...interface{}) { // } // return r, nil // }) -func (ctx *ProxyCtx) Warnf(msg string, argv ...interface{}) { +func (ctx *ProxyCtx) Warnf(msg string, argv ...any) { ctx.printf("WARN: "+msg, argv...) } -var charsetFinder = regexp.MustCompile("charset=([^ ;]*)") - // Will try to infer the character set of the request from the headers. // Returns the empty string if we don't know which character set it used. // Currently it will look for charset= in the Content-Type header of the request. func (ctx *ProxyCtx) Charset() string { - charsets := charsetFinder.FindStringSubmatch(ctx.Resp.Header.Get("Content-Type")) - if charsets == nil { - return "" + contentType := ctx.Resp.Header.Get("Content-Type") + if _, params, err := mime.ParseMediaType(contentType); err == nil { + if cs, ok := params["charset"]; ok { + return cs + } } - return charsets[1] + return "" } diff --git a/vendor/github.com/elazarl/goproxy/dispatcher.go b/vendor/github.com/elazarl/goproxy/dispatcher.go index 25c949c..0bd554b 100644 --- a/vendor/github.com/elazarl/goproxy/dispatcher.go +++ b/vendor/github.com/elazarl/goproxy/dispatcher.go @@ -2,7 +2,7 @@ package goproxy import ( "bytes" - "io/ioutil" + "io" "net" "net/http" "regexp" @@ -10,7 +10,7 @@ import ( ) // ReqCondition.HandleReq will decide whether or not to use the ReqHandler on an HTTP request -// before sending it to the remote server +// before sending it to the remote server. type ReqCondition interface { RespCondition HandleReq(req *http.Request, ctx *ProxyCtx) bool @@ -23,10 +23,10 @@ type RespCondition interface { HandleResp(resp *http.Response, ctx *ProxyCtx) bool } -// ReqConditionFunc.HandleReq(req,ctx) <=> ReqConditionFunc(req,ctx) +// ReqConditionFunc.HandleReq(req,ctx) <=> ReqConditionFunc(req,ctx). type ReqConditionFunc func(req *http.Request, ctx *ProxyCtx) bool -// RespConditionFunc.HandleResp(resp,ctx) <=> RespConditionFunc(resp,ctx) +// RespConditionFunc.HandleResp(resp,ctx) <=> RespConditionFunc(resp,ctx). type RespConditionFunc func(resp *http.Response, ctx *ProxyCtx) bool func (c ReqConditionFunc) HandleReq(req *http.Request, ctx *ProxyCtx) bool { @@ -49,9 +49,17 @@ func (c RespConditionFunc) HandleResp(resp *http.Response, ctx *ProxyCtx) bool { // requests to url 'http://host/x' func UrlHasPrefix(prefix string) ReqConditionFunc { return func(req *http.Request, ctx *ProxyCtx) bool { + // Make sure to include the / as the first path character when we do a match + // using the host + relativePath := req.URL.Path + if length := len(relativePath); length == 0 || (length > 0 && relativePath[0] != '/') { + relativePath = "/" + relativePath + } + // We use the original value to distinguish between "" and "/" in the user specified string return strings.HasPrefix(req.URL.Path, prefix) || - strings.HasPrefix(req.URL.Host+req.URL.Path, prefix) || - strings.HasPrefix(req.URL.Scheme+req.URL.Host+req.URL.Path, prefix) + strings.HasPrefix(req.URL.Host+relativePath, prefix) || + // Scheme value is something like "https", we must include the :// characters + strings.HasPrefix(req.URL.Scheme+"://"+req.URL.Host+relativePath, prefix) } } @@ -85,7 +93,7 @@ func ReqHostMatches(regexps ...*regexp.Regexp) ReqConditionFunc { } // ReqHostIs returns a ReqCondition, testing whether the host to which the request is directed to equal -// to one of the given strings +// to one of the given strings. func ReqHostIs(hosts ...string) ReqConditionFunc { hostSet := make(map[string]bool) for _, h := range hosts { @@ -97,19 +105,26 @@ func ReqHostIs(hosts ...string) ReqConditionFunc { } } -var localHostIpv4 = regexp.MustCompile(`127\.0\.0\.\d+`) - -// IsLocalHost checks whether the destination host is explicitly local host -// (buggy, there can be IPv6 addresses it doesn't catch) +// IsLocalHost checks whether the destination host is localhost. var IsLocalHost ReqConditionFunc = func(req *http.Request, ctx *ProxyCtx) bool { - return req.URL.Host == "::1" || - req.URL.Host == "0:0:0:0:0:0:0:1" || - localHostIpv4.MatchString(req.URL.Host) || - req.URL.Host == "localhost" + h := req.URL.Hostname() + if h == "localhost" { + return true + } + if ip := net.ParseIP(h); ip != nil { + return ip.IsLoopback() + } + + // In case of IPv6 without a port number Hostname() sometimes returns the invalid value. + if ip := net.ParseIP(req.URL.Host); ip != nil { + return ip.IsLoopback() + } + + return false } // UrlMatches returns a ReqCondition testing whether the destination URL -// of the request matches the given regexp, with or without prefix +// of the request matches the given regexp, with or without prefix. func UrlMatches(re *regexp.Regexp) ReqConditionFunc { return func(req *http.Request, ctx *ProxyCtx) bool { return re.MatchString(req.URL.Path) || @@ -117,14 +132,32 @@ func UrlMatches(re *regexp.Regexp) ReqConditionFunc { } } -// DstHostIs returns a ReqCondition testing wether the host in the request url is the given string +// DstHostIs returns a ReqCondition testing wether the host in the request url is the given string. func DstHostIs(host string) ReqConditionFunc { + // Make sure to perform a case-insensitive host check + host = strings.ToLower(host) + var port string + + // Check if the user specified a custom port that we need to match + if strings.Contains(host, ":") { + hostOnly, portOnly, err := net.SplitHostPort(host) + if err == nil { + host = hostOnly + port = portOnly + } + } + return func(req *http.Request, ctx *ProxyCtx) bool { - return req.URL.Host == host + // Check port matching only if it was specified + if port != "" && port != req.URL.Port() { + return false + } + + return strings.ToLower(req.URL.Hostname()) == host } } -// SrcIpIs returns a ReqCondition testing whether the source IP of the request is one of the given strings +// SrcIpIs returns a ReqCondition testing whether the source IP of the request is one of the given strings. func SrcIpIs(ips ...string) ReqCondition { return ReqConditionFunc(func(req *http.Request, ctx *ProxyCtx) bool { for _, ip := range ips { @@ -136,7 +169,7 @@ func SrcIpIs(ips ...string) ReqCondition { }) } -// Not returns a ReqCondition negating the given ReqCondition +// Not returns a ReqCondition negating the given ReqCondition. func Not(r ReqCondition) ReqConditionFunc { return func(req *http.Request, ctx *ProxyCtx) bool { return !r.HandleReq(req, ctx) @@ -162,7 +195,7 @@ func ContentTypeIs(typ string, types ...string) RespCondition { } // StatusCodeIs returns a RespCondition, testing whether or not the HTTP status -// code is one of the given ints +// code is one of the given ints. func StatusCodeIs(codes ...int) RespCondition { codeSet := make(map[int]bool) for _, c := range codes { @@ -181,19 +214,21 @@ func StatusCodeIs(codes ...int) RespCondition { // You will use the ReqProxyConds struct to register a ReqHandler, that would filter // the request, only if all the given ReqCondition matched. // Typical usage: +// // proxy.OnRequest(UrlIs("example.com/foo"),UrlMatches(regexp.MustParse(`.*\.exampl.\com\./.*`)).Do(...) func (proxy *ProxyHttpServer) OnRequest(conds ...ReqCondition) *ReqProxyConds { return &ReqProxyConds{proxy, conds} } -// ReqProxyConds aggregate ReqConditions for a ProxyHttpServer. Upon calling Do, it will register a ReqHandler that would +// ReqProxyConds aggregate ReqConditions for a ProxyHttpServer. +// Upon calling Do, it will register a ReqHandler that would // handle the request if all conditions on the HTTP request are met. type ReqProxyConds struct { proxy *ProxyHttpServer reqConds []ReqCondition } -// DoFunc is equivalent to proxy.OnRequest().Do(FuncReqHandler(f)) +// DoFunc is equivalent to proxy.OnRequest().Do(FuncReqHandler(f)). func (pcond *ReqProxyConds) DoFunc(f func(req *http.Request, ctx *ProxyCtx) (*http.Request, *http.Response)) { pcond.Do(FuncReqHandler(f)) } @@ -201,6 +236,7 @@ func (pcond *ReqProxyConds) DoFunc(f func(req *http.Request, ctx *ProxyCtx) (*ht // ReqProxyConds.Do will register the ReqHandler on the proxy, // the ReqHandler will handle the HTTP request if all the conditions // aggregated in the ReqProxyConds are met. Typical usage: +// // proxy.OnRequest().Do(handler) // will call handler.Handle(req,ctx) on every request to the proxy // proxy.OnRequest(cond1,cond2).Do(handler) // // given request to the proxy, will test if cond1.HandleReq(req,ctx) && cond2.HandleReq(req,ctx) are true @@ -227,6 +263,7 @@ func (pcond *ReqProxyConds) Do(h ReqHandler) { // connection. // The ConnectAction struct contains possible tlsConfig that will be used for eavesdropping. If nil, the proxy // will use the default tls configuration. +// // proxy.OnRequest().HandleConnect(goproxy.AlwaysReject) // rejects all CONNECT requests func (pcond *ReqProxyConds) HandleConnect(h HttpsHandler) { pcond.proxy.httpsHandlers = append(pcond.proxy.httpsHandlers, @@ -242,6 +279,7 @@ func (pcond *ReqProxyConds) HandleConnect(h HttpsHandler) { // HandleConnectFunc is equivalent to HandleConnect, // for example, accepting CONNECT request if they contain a password in header +// // io.WriteString(h,password) // passHash := h.Sum(nil) // proxy.OnRequest().HandleConnectFunc(func(host string, ctx *ProxyCtx) (*ConnectAction, string) { @@ -256,6 +294,11 @@ func (pcond *ReqProxyConds) HandleConnectFunc(f func(host string, ctx *ProxyCtx) pcond.HandleConnect(FuncHttpsHandler(f)) } +// HijackConnect registers a handler that takes full control of the raw net.Conn +// for CONNECT requests that match the aggregated conditions. +// The handler receives the original HTTP request, the raw client connection, and the proxy context. +// It is the handler's responsibility to write an HTTP response (e.g. "HTTP/1.1 200 OK\r\n\r\n") +// and close the connection when done. func (pcond *ReqProxyConds) HijackConnect(f func(req *http.Request, client net.Conn, ctx *ProxyCtx)) { pcond.proxy.httpsHandlers = append(pcond.proxy.httpsHandlers, FuncHttpsHandler(func(host string, ctx *ProxyCtx) (*ConnectAction, string) { @@ -277,7 +320,7 @@ type ProxyConds struct { respCond []RespCondition } -// ProxyConds.DoFunc is equivalent to proxy.OnResponse().Do(FuncRespHandler(f)) +// ProxyConds.DoFunc is equivalent to proxy.OnResponse().Do(FuncRespHandler(f)). func (pcond *ProxyConds) DoFunc(f func(resp *http.Response, ctx *ProxyCtx) *http.Response) { pcond.Do(FuncRespHandler(f)) } @@ -302,6 +345,7 @@ func (pcond *ProxyConds) Do(h RespHandler) { } // OnResponse is used when adding a response-filter to the HTTP proxy, usual pattern is +// // proxy.OnResponse(cond1,cond2).Do(handler) // handler.Handle(resp,ctx) will be used // // if cond1.HandleResp(resp) && cond2.HandleResp(resp) func (proxy *ProxyHttpServer) OnResponse(conds ...RespCondition) *ProxyConds { @@ -310,6 +354,7 @@ func (proxy *ProxyHttpServer) OnResponse(conds ...RespCondition) *ProxyConds { // AlwaysMitm is a HttpsHandler that always eavesdrop https connections, for example to // eavesdrop all https connections to www.google.com, we can use +// // proxy.OnRequest(goproxy.ReqHostIs("www.google.com")).HandleConnect(goproxy.AlwaysMitm) var AlwaysMitm FuncHttpsHandler = func(host string, ctx *ProxyCtx) (*ConnectAction, string) { return MitmConnect, host @@ -317,6 +362,7 @@ var AlwaysMitm FuncHttpsHandler = func(host string, ctx *ProxyCtx) (*ConnectActi // AlwaysReject is a HttpsHandler that drops any CONNECT request, for example, this code will disallow // connections to hosts on any other port than 443 +// // proxy.OnRequest(goproxy.Not(goproxy.ReqHostMatches(regexp.MustCompile(":443$"))). // HandleConnect(goproxy.AlwaysReject) var AlwaysReject FuncHttpsHandler = func(host string, ctx *ProxyCtx) (*ConnectAction, string) { @@ -328,14 +374,14 @@ var AlwaysReject FuncHttpsHandler = func(host string, ctx *ProxyCtx) (*ConnectAc // and will replace the body of the original response with the resulting byte array. func HandleBytes(f func(b []byte, ctx *ProxyCtx) []byte) RespHandler { return FuncRespHandler(func(resp *http.Response, ctx *ProxyCtx) *http.Response { - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { ctx.Warnf("Cannot read response %s", err) return resp } resp.Body.Close() - resp.Body = ioutil.NopCloser(bytes.NewBuffer(f(b, ctx))) + resp.Body = io.NopCloser(bytes.NewBuffer(f(b, ctx))) return resp }) } diff --git a/vendor/github.com/elazarl/goproxy/doc.go b/vendor/github.com/elazarl/goproxy/doc.go index 6f44317..1ba20bf 100644 --- a/vendor/github.com/elazarl/goproxy/doc.go +++ b/vendor/github.com/elazarl/goproxy/doc.go @@ -23,7 +23,7 @@ Adding a header to each request return r, nil }) -Note that the function is called before the proxy sends the request to the server +> Note that the function is called before the proxy sends the request to the server For printing the content type of all incoming responses @@ -60,7 +60,9 @@ Finally, we have convenience function to throw a quick response proxy.OnResponse(hasGoProxyHeader).DoFunc(func(r*http.Response,ctx *goproxy.ProxyCtx)*http.Response { r.Body.Close() - return goproxy.NewResponse(ctx.Req, goproxy.ContentTypeText, http.StatusForbidden, "Can't see response with X-GoProxy header!") + return goproxy.NewResponse( + ctx.Req, goproxy.ContentTypeText, http.StatusForbidden, "Can't see response with X-GoProxy header!" + ) }) we close the body of the original response, and return a new 403 response with a short message. @@ -95,6 +97,5 @@ Will warn if multiple versions of jquery are used in the same domain. 6. https://github.com/elazarl/goproxy/blob/master/examples/goproxy-upside-down-ternet/ Modifies image files in an HTTP response via goproxy's image extension found in ext/. - */ package goproxy diff --git a/vendor/github.com/elazarl/goproxy/h2.go b/vendor/github.com/elazarl/goproxy/h2.go deleted file mode 100644 index 7c0f357..0000000 --- a/vendor/github.com/elazarl/goproxy/h2.go +++ /dev/null @@ -1,171 +0,0 @@ -package goproxy - -import ( - "bufio" - "crypto/tls" - "errors" - "io" - "net" - "net/http" - "strings" - - "golang.org/x/net/http2" -) - -// H2Transport is an implementation of RoundTripper that abstracts an entire -// HTTP/2 session, sending all client frames to the server and responses back -// to the client. -type H2Transport struct { - ClientReader io.Reader - ClientWriter io.Writer - TLSConfig *tls.Config - Host string -} - -// RoundTrip executes an HTTP/2 session (including all contained streams). -// The request and response are ignored but any error encountered during the -// proxying from the session is returned as a result of the invocation. -func (r *H2Transport) RoundTrip(prefaceReq *http.Request) (*http.Response, error) { - raddr := r.Host - if !strings.Contains(raddr, ":") { - raddr = raddr + ":443" - } - rawServerTLS, err := dial("tcp", raddr) - if err != nil { - return nil, err - } - defer rawServerTLS.Close() - // Ensure that we only advertise HTTP/2 as the accepted protocol. - r.TLSConfig.NextProtos = []string{http2.NextProtoTLS} - // Initiate TLS and check remote host name against certificate. - rawServerTLS = tls.Client(rawServerTLS, r.TLSConfig) - if err = rawServerTLS.(*tls.Conn).Handshake(); err != nil { - return nil, err - } - if r.TLSConfig == nil || !r.TLSConfig.InsecureSkipVerify { - if err = rawServerTLS.(*tls.Conn).VerifyHostname(raddr[:strings.LastIndex(raddr, ":")]); err != nil { - return nil, err - } - } - // Send new client preface to match the one parsed in req. - if _, err := io.WriteString(rawServerTLS, http2.ClientPreface); err != nil { - return nil, err - } - serverTLSReader := bufio.NewReader(rawServerTLS) - cToS := http2.NewFramer(rawServerTLS, r.ClientReader) - sToC := http2.NewFramer(r.ClientWriter, serverTLSReader) - errSToC := make(chan error) - errCToS := make(chan error) - go func() { - for { - if err := proxyFrame(sToC); err != nil { - errSToC <- err - break - } - } - }() - go func() { - for { - if err := proxyFrame(cToS); err != nil { - errCToS <- err - break - } - } - }() - for i := 0; i < 2; i++ { - select { - case err := <-errSToC: - if err != io.EOF { - return nil, err - } - case err := <-errCToS: - if err != io.EOF { - return nil, err - } - } - } - return nil, nil -} - -func dial(network, addr string) (c net.Conn, err error) { - addri, err := net.ResolveTCPAddr(network, addr) - if err != nil { - return - } - c, err = net.DialTCP(network, nil, addri) - return -} - -// proxyFrame reads a single frame from the Framer and, when successful, writes -// a ~identical one back to the Framer. -func proxyFrame(fr *http2.Framer) error { - f, err := fr.ReadFrame() - if err != nil { - return err - } - switch f.Header().Type { - case http2.FrameData: - tf := f.(*http2.DataFrame) - terr := fr.WriteData(tf.StreamID, tf.StreamEnded(), tf.Data()) - if terr == nil && tf.StreamEnded() { - terr = io.EOF - } - return terr - case http2.FrameHeaders: - tf := f.(*http2.HeadersFrame) - terr := fr.WriteHeaders(http2.HeadersFrameParam{ - StreamID: tf.StreamID, - BlockFragment: tf.HeaderBlockFragment(), - EndStream: tf.StreamEnded(), - EndHeaders: tf.HeadersEnded(), - PadLength: 0, - Priority: tf.Priority, - }) - if terr == nil && tf.StreamEnded() { - terr = io.EOF - } - return terr - case http2.FrameContinuation: - tf := f.(*http2.ContinuationFrame) - return fr.WriteContinuation(tf.StreamID, tf.HeadersEnded(), tf.HeaderBlockFragment()) - case http2.FrameGoAway: - tf := f.(*http2.GoAwayFrame) - return fr.WriteGoAway(tf.StreamID, tf.ErrCode, tf.DebugData()) - case http2.FramePing: - tf := f.(*http2.PingFrame) - return fr.WritePing(tf.IsAck(), tf.Data) - case http2.FrameRSTStream: - tf := f.(*http2.RSTStreamFrame) - return fr.WriteRSTStream(tf.StreamID, tf.ErrCode) - case http2.FrameSettings: - tf := f.(*http2.SettingsFrame) - if tf.IsAck() { - return fr.WriteSettingsAck() - } - var settings []http2.Setting - // NOTE: If we want to parse headers, need to handle - // settings where s.ID == http2.SettingHeaderTableSize and - // accordingly update the Framer options. - for i := 0; i < tf.NumSettings(); i++ { - settings = append(settings, tf.Setting(i)) - } - return fr.WriteSettings(settings...) - case http2.FrameWindowUpdate: - tf := f.(*http2.WindowUpdateFrame) - return fr.WriteWindowUpdate(tf.StreamID, tf.Increment) - case http2.FramePriority: - tf := f.(*http2.PriorityFrame) - return fr.WritePriority(tf.StreamID, tf.PriorityParam) - case http2.FramePushPromise: - tf := f.(*http2.PushPromiseFrame) - return fr.WritePushPromise(http2.PushPromiseParam{ - StreamID: tf.StreamID, - PromiseID: tf.PromiseID, - BlockFragment: tf.HeaderBlockFragment(), - EndHeaders: tf.HeadersEnded(), - PadLength: 0, - }) - default: - return errors.New("Unsupported frame: " + string(f.Header().Type)) - } -} diff --git a/vendor/github.com/elazarl/goproxy/http.go b/vendor/github.com/elazarl/goproxy/http.go new file mode 100644 index 0000000..ce32e4f --- /dev/null +++ b/vendor/github.com/elazarl/goproxy/http.go @@ -0,0 +1,138 @@ +package goproxy + +import ( + "io" + "net/http" + "strings" + "sync/atomic" +) + +func (proxy *ProxyHttpServer) handleHttp(w http.ResponseWriter, r *http.Request) { + ctx := &ProxyCtx{Req: r, Session: atomic.AddInt64(&proxy.sess, 1), Proxy: proxy} + + ctx.Logf("Got request %v %v %v %v", r.URL.Path, r.Host, r.Method, r.URL.String()) + if !r.URL.IsAbs() { + proxy.NonproxyHandler.ServeHTTP(w, r) + return + } + r, resp := proxy.filterRequest(r, ctx) + + if resp == nil { + if !proxy.KeepHeader { + RemoveProxyHeaders(ctx, r) + } + + var err error + resp, err = ctx.RoundTrip(r) + if err != nil { + ctx.Error = err + } + } + + var origBody io.ReadCloser + + if resp != nil { + origBody = resp.Body + defer origBody.Close() + } + + resp = proxy.filterResponse(resp, ctx) + + if resp == nil { + var errorString string + if ctx.Error != nil { + errorString = "error read response " + r.URL.Host + " : " + ctx.Error.Error() + ctx.Logf(errorString) + http.Error(w, ctx.Error.Error(), http.StatusInternalServerError) + } else { + errorString = "error read response " + r.URL.Host + ctx.Logf(errorString) + http.Error(w, errorString, http.StatusInternalServerError) + } + return + } + ctx.Logf("Copying response to client %v [%d]", resp.Status, resp.StatusCode) + // http.ResponseWriter will take care of filling the correct response length + // Setting it now, might impose wrong value, contradicting the actual new + // body the user returned. + // We keep the original body to remove the header only if things changed. + // This will prevent problems with HEAD requests where there's no body, yet, + // the Content-Length header should be set. + if origBody != resp.Body { + resp.Header.Del("Content-Length") + } + copyHeaders(w.Header(), resp.Header, proxy.KeepDestinationHeaders) + + // Announce trailers known at this point (HTTP/1.1 with pre-announced + // Trailer header). Setting "Trailer" before WriteHeader makes + // http.Server commit to chunked encoding (h1) or a trailing HEADERS + // frame (h2), which is required for any trailers to be forwarded. + // Mirrors net/http/httputil.ReverseProxy. + announcedTrailers := len(resp.Trailer) + if announcedTrailers > 0 { + trailerKeys := make([]string, 0, announcedTrailers) + for k := range resp.Trailer { + trailerKeys = append(trailerKeys, k) + } + w.Header().Add("Trailer", strings.Join(trailerKeys, ", ")) + } + w.WriteHeader(resp.StatusCode) + + if isWebSocketHandshake(resp.Header) { + ctx.Logf("Response looks like websocket upgrade.") + + // We have already written the "101 Switching Protocols" response, + // now we hijack the connection to send WebSocket data + if clientConn, err := proxy.hijackConnection(ctx, w); err == nil { + wsConn, ok := resp.Body.(io.ReadWriter) + if !ok { + ctx.Warnf("Unable to use Websocket connection") + return + } + proxy.proxyWebsocket(ctx, wsConn, clientConn) + } + return + } + + var copyWriter io.Writer = w + // Content-Type header may also contain charset definition, so here we need to check the prefix. + // Transfer-Encoding can be a list of comma separated values, so we use Contains() for it. + if strings.HasPrefix(w.Header().Get("content-type"), "text/event-stream") || + strings.Contains(w.Header().Get("transfer-encoding"), "chunked") { + // server-side events, flush the buffered data to the client. + copyWriter = &flushWriter{w: w} + } + + nr, err := io.Copy(copyWriter, resp.Body) + if err := resp.Body.Close(); err != nil { + ctx.Warnf("Can't close response body %v", err) + } + + // Forward upstream response trailers. Two cases: + // 1. resp.Trailer count == announcedTrailers: every trailer was + // pre-announced, so http.Server is already looking for them + // under the unprefixed names — write values there. + // 2. resp.Trailer count > announcedTrailers (HTTP/2 servers, or + // late additions): use http.TrailerPrefix so http.Server emits + // them as trailers without needing the leading announcement. + // We still need a Flush below to force chunked encoding for + // bodies short enough that http.Server would otherwise inline + // them with Content-Length and silently drop trailers. + if len(resp.Trailer) > 0 { + // Force chunking even when the body is small / fully buffered. + if rc := http.NewResponseController(w); rc != nil { + _ = rc.Flush() + } + if len(resp.Trailer) == announcedTrailers { + copyHeaders(w.Header(), resp.Trailer, proxy.KeepDestinationHeaders) + } else { + for k, vs := range resp.Trailer { + k = http.TrailerPrefix + k + for _, v := range vs { + w.Header().Add(k, v) + } + } + } + } + ctx.Logf("Copied %v bytes to client error=%v", nr, err) +} diff --git a/vendor/github.com/elazarl/goproxy/http2.go b/vendor/github.com/elazarl/goproxy/http2.go new file mode 100644 index 0000000..4628696 --- /dev/null +++ b/vendor/github.com/elazarl/goproxy/http2.go @@ -0,0 +1,274 @@ +package goproxy + +import ( + "context" + "crypto/tls" + "errors" + "io" + "mime" + "net" + "net/http" + "strings" + "sync/atomic" + "time" + + "golang.org/x/net/http2" +) + +// h2StreamConn wraps an HTTP/2 CONNECT stream as a net.Conn. +// +// When a CONNECT request arrives over HTTP/2, hijacking is unavailable. +// We implement net.Conn directly on top of the H2 stream: +// - Read: r.Body (the request body carries client -> proxy data) +// - Write: w (the response body carries proxy -> client data) +type h2StreamConn struct { + r io.ReadCloser + w http.ResponseWriter + ctrl *http.ResponseController + local net.Addr + remote net.Addr +} + +// responseWriterProvider is implemented by h2StreamConn so that httpError +// can recover an http.ResponseWriter from an io.Writer in H2 mode. +type responseWriterProvider interface { + ResponseWriter() http.ResponseWriter +} + +func newH2StreamConn(w http.ResponseWriter, r *http.Request) *h2StreamConn { + return &h2StreamConn{ + r: r.Body, + w: w, + ctrl: http.NewResponseController(w), + local: h2streamAddr("h2-proxy"), + remote: h2streamAddr(r.RemoteAddr), + } +} + +func (c *h2StreamConn) ResponseWriter() http.ResponseWriter { + return c.w +} + +func (c *h2StreamConn) Read(b []byte) (int, error) { + return c.r.Read(b) +} + +func (c *h2StreamConn) Write(b []byte) (int, error) { + n, err := c.w.Write(b) + if err == nil { + _ = c.ctrl.Flush() + } + return n, err +} + +func (c *h2StreamConn) Close() error { + return c.r.Close() +} + +func (c *h2StreamConn) LocalAddr() net.Addr { + return c.local +} + +func (c *h2StreamConn) RemoteAddr() net.Addr { + return c.remote +} + +func (c *h2StreamConn) SetDeadline(t time.Time) error { + rerr := c.ctrl.SetReadDeadline(t) + werr := c.ctrl.SetWriteDeadline(t) + if rerr != nil { + return rerr + } + return werr +} + +func (c *h2StreamConn) SetReadDeadline(t time.Time) error { + return c.ctrl.SetReadDeadline(t) +} + +func (c *h2StreamConn) SetWriteDeadline(t time.Time) error { + return c.ctrl.SetWriteDeadline(t) +} + +type h2streamAddr string + +func (a h2streamAddr) Network() string { + return "h2" +} + +func (a h2streamAddr) String() string { + return string(a) +} + +// serveH2Mitm serves an HTTP/2 MITM connection via an embedded http2.Server. +// - client is the underlying connection (*tls.Conn for ALPN-h2, plain net.Conn for h2c). +// - host is the CONNECT target (e.g. "example.com:443"). +// - parentCtx carries the UserData / CertStore / RoundTripper from the CONNECT handler, +// propagated into every per-stream ProxyCtx. +func (proxy *ProxyHttpServer) serveH2Mitm(client net.Conn, host string, parentCtx *ProxyCtx) { + scheme := "https" + if _, isTLS := client.(*tls.Conn); !isTLS { + scheme = "http" + } + + proxy.h2Server.ServeConn(client, &http2.ServeConnOpts{ + Context: context.Background(), + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxy.handleH2MitmStream(w, r, host, scheme, parentCtx) + }), + }) +} + +// handleH2MitmStream handles a single HTTP/2 stream inside a MITM session, +// running request/response filters and forwarding to the upstream server. +func (proxy *ProxyHttpServer) handleH2MitmStream( + w http.ResponseWriter, + r *http.Request, + host, scheme string, + parentCtx *ProxyCtx, +) { + // r.Host contains the :authority pseudo-header: prefer it over the + // fallback CONNECT host so virtual-hosting works correctly. + if r.Host != "" { + r.URL.Host = r.Host + } else if r.URL.Host == "" { + r.URL.Host = host + } + r.URL.Scheme = scheme + + // Carry over the connecting client's address so that IP-matching + // filters (e.g. SrcIpIs) keep working. + r.RemoteAddr = parentCtx.Req.RemoteAddr + + reqCtx, finishRequest := context.WithCancel(r.Context()) + defer finishRequest() + r = r.WithContext(reqCtx) + + ctx := &ProxyCtx{ + Req: r, + Session: atomic.AddInt64(&proxy.sess, 1), + Proxy: proxy, + UserData: parentCtx.UserData, + RoundTripper: parentCtx.RoundTripper, + certStore: parentCtx.certStore, + } + + req, resp := proxy.filterRequest(r, ctx) + if resp == nil { + removeH2HopByHopHeaders(req) + if !proxy.KeepHeader { + RemoveProxyHeaders(ctx, req) + } + + // bodyless h2 requests arrive with a non-nil empty Body; forwarding as-is + // makes the upstream transport send a phantom body (-1). keep it bodyless. + if req.ContentLength == 0 { + req.Body = http.NoBody + } + + var err error + resp, err = ctx.RoundTrip(req) + if err != nil { + ctx.Warnf("HTTP/2 MITM: upstream RoundTrip failed: %v", err) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + ctx.Logf("resp %v", resp.Status) + } + + origBody := resp.Body + resp = proxy.filterResponse(resp, ctx) + defer resp.Body.Close() + + copyHeaders(w.Header(), resp.Header, proxy.KeepDestinationHeaders) + + // If the body was replaced by a filter, drop Content-Length so + // the http2 framer can stream it without a length mismatch. + if resp.Body != origBody { + w.Header().Del("Content-Length") + } + + // Announce pre-known trailers before WriteHeader (see handleHttp in http.go). + announcedTrailers := len(resp.Trailer) + if announcedTrailers > 0 { + trailerKeys := make([]string, 0, announcedTrailers) + for k := range resp.Trailer { + trailerKeys = append(trailerKeys, k) + } + w.Header().Add("Trailer", strings.Join(trailerKeys, ", ")) + } + + w.WriteHeader(resp.StatusCode) + + if resp.Body != nil { + if shouldFlushStreaming(resp) { + // Streaming (gRPC/Connect/SSE/chunked): flush each chunk so it reaches the + // client as it arrives. Mirrors net/http/httputil.ReverseProxy.flushInterval. + rc := http.NewResponseController(w) + buf := make([]byte, 32*1024) + for { + nr, er := resp.Body.Read(buf) + if nr > 0 { + if _, ew := w.Write(buf[:nr]); ew != nil { + ctx.Warnf("HTTP/2 MITM: error writing response body: %v", ew) + break + } + _ = rc.Flush() + } + if er != nil { + // Mirror net/http/httputil.ReverseProxy.copyBuffer: io.EOF is the + // normal end of stream and context.Canceled means the client went + // away or cancelled the request, so neither is worth logging. + if er != io.EOF && !errors.Is(er, context.Canceled) { + ctx.Warnf("HTTP/2 MITM: error reading response body: %v", er) + } + break + } + } + } else { + // Fixed-length response: let the h2 server batch writes for throughput. + if _, err := io.Copy(w, resp.Body); err != nil { + ctx.Warnf("HTTP/2 MITM: error writing response body: %v", err) + } + } + } + + // Forward response trailers after the body: pre-announced by name, the rest + // (h2/gRPC send them unannounced) via http.TrailerPrefix; Flush forces + // chunking. Mirrors handleHttp in http.go. + if len(resp.Trailer) > 0 { + if rc := http.NewResponseController(w); rc != nil { + _ = rc.Flush() + } + if len(resp.Trailer) == announcedTrailers { + copyHeaders(w.Header(), resp.Trailer, proxy.KeepDestinationHeaders) + } else { + for k, vs := range resp.Trailer { + k = http.TrailerPrefix + k + for _, v := range vs { + w.Header().Add(k, v) + } + } + } + } +} + +// shouldFlushStreaming reports whether resp should be forwarded with a flush after +// each chunk, mirroring net/http/httputil.ReverseProxy.flushInterval: Server-Sent +// Events, or any response with an unknown (-1) Content-Length (gRPC, Connect, chunked). +func shouldFlushStreaming(resp *http.Response) bool { + if baseCT, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); baseCT == "text/event-stream" { + return true + } + return resp.ContentLength == -1 +} + +// removeH2HopByHopHeaders deletes HTTP/1.x hop-by-hop headers that are +// illegal in HTTP/2 (RFC 9113 §8.2.2). +func removeH2HopByHopHeaders(r *http.Request) { + r.Header.Del("Connection") + r.Header.Del("Keep-Alive") + r.Header.Del("Proxy-Connection") + r.Header.Del("Transfer-Encoding") + r.Header.Del("Upgrade") +} diff --git a/vendor/github.com/elazarl/goproxy/https.go b/vendor/github.com/elazarl/goproxy/https.go index 271b55c..4f88051 100644 --- a/vendor/github.com/elazarl/goproxy/https.go +++ b/vendor/github.com/elazarl/goproxy/https.go @@ -2,45 +2,140 @@ package goproxy import ( "bufio" + "bytes" + "context" "crypto/tls" "errors" "fmt" "io" - "io/ioutil" "net" "net/http" "net/url" "os" - "regexp" - "strconv" + "slices" "strings" "sync" "sync/atomic" + "time" + + "github.com/elazarl/goproxy/internal/http1parser" + "github.com/elazarl/goproxy/internal/signer" + "golang.org/x/net/http2" ) +var responseHeadTerminator = []byte("\r\n\r\n") + +type responseHeadWriter struct { + writer io.Writer + head bytes.Buffer + wroteHead bool +} + +func (w *responseHeadWriter) Write(p []byte) (int, error) { + if w.wroteHead { + return w.writer.Write(p) + } + + buffered := w.head.Len() + _, _ = w.head.Write(p) + headEnd := bytes.Index(w.head.Bytes(), responseHeadTerminator) + if headEnd < 0 { + return len(p), nil + } + headEnd += len(responseHeadTerminator) + + data := w.head.Bytes() + n, err := w.writer.Write(data[:headEnd]) + if err != nil || n != headEnd { + current := max(0, min(len(p), n-buffered)) + if err == nil { + err = io.ErrShortWrite + } + return current, err + } + + w.wroteHead = true + body := data[headEnd:] + w.head.Reset() + if len(body) == 0 { + return len(p), nil + } + + n, err = w.writer.Write(body) + return headEnd - buffered + n, err +} + +// ConnectActionLiteral defines the action the proxy should take +// when it receives an HTTP CONNECT request from a client. type ConnectActionLiteral int const ( - ConnectAccept = iota + // ConnectAccept instructs the proxy to accept the CONNECT request + // and establish a transparent TCP tunnel to the destination host. + // The proxy will forward raw bytes in both directions without inspecting them. + ConnectAccept ConnectActionLiteral = iota + + // ConnectReject instructs the proxy to reject the CONNECT request + // and immediately close the connection with the client. ConnectReject + + // ConnectMitm instructs the proxy to perform a Man-in-the-Middle (MITM) + // attack on the CONNECT tunnel. The proxy generates a dynamic TLS certificate + // for the target host, signed by its CA (see GoproxyCa), and establishes + // separate TLS connections with both the client and the destination server. + // All request and response handlers remain active on this intercepted connection. ConnectMitm + + // ConnectHijack instructs the proxy to hand the raw net.Conn to the function + // defined in ConnectAction.Hijack, giving full low-level control of the + // connection to the caller. The hijack function is responsible for sending + // an HTTP response (e.g. "HTTP/1.1 200 OK") back to the client. ConnectHijack + + // ConnectHTTPMitm is deprecated: use ConnectMitm instead. ConnectHTTPMitm + + // ConnectProxyAuthHijack instructs the proxy to hijack the CONNECT connection + // after a proxy authentication failure, allowing the handler to send + // a custom authentication challenge or error response to the client. ConnectProxyAuthHijack ) var ( - OkConnect = &ConnectAction{Action: ConnectAccept, TLSConfig: TLSConfigFromCA(&GoproxyCa)} - MitmConnect = &ConnectAction{Action: ConnectMitm, TLSConfig: TLSConfigFromCA(&GoproxyCa)} + // OkConnect is a ready-to-use ConnectAction that accepts the CONNECT request + // and creates a transparent TCP tunnel to the destination host, using the built-in CA. + OkConnect = &ConnectAction{Action: ConnectAccept, TLSConfig: TLSConfigFromCA(&GoproxyCa)} + + // MitmConnect is a ready-to-use ConnectAction that performs MITM interception, + // signing dynamic TLS certificates with the built-in CA (GoproxyCa). + // Use proxy.CertStore to cache generated certificates and save CPU in production. + MitmConnect = &ConnectAction{Action: ConnectMitm, TLSConfig: TLSConfigFromCA(&GoproxyCa)} + + // HTTPMitmConnect is deprecated: use MitmConnect instead. HTTPMitmConnect = &ConnectAction{Action: ConnectHTTPMitm, TLSConfig: TLSConfigFromCA(&GoproxyCa)} - RejectConnect = &ConnectAction{Action: ConnectReject, TLSConfig: TLSConfigFromCA(&GoproxyCa)} - httpsRegexp = regexp.MustCompile(`^https:\/\/`) + + // RejectConnect is a ready-to-use ConnectAction that rejects the CONNECT request + // and closes the connection with the client. + RejectConnect = &ConnectAction{Action: ConnectReject, TLSConfig: TLSConfigFromCA(&GoproxyCa)} ) +var _errorRespMaxLength int64 = 500 + +const _tlsRecordTypeHandshake = byte(22) + +type readBufferedConn struct { + net.Conn + r io.Reader +} + +func (c *readBufferedConn) Read(p []byte) (int, error) { + return c.r.Read(p) +} + // ConnectAction enables the caller to override the standard connect flow. // When Action is ConnectHijack, it is up to the implementer to send the // HTTP 200, or any other valid http response back to the client from within the -// Hijack func +// Hijack func. type ConnectAction struct { Action ConnectActionLiteral Hijack func(req *http.Request, client net.Conn, ctx *ProxyCtx) @@ -48,39 +143,31 @@ type ConnectAction struct { } func stripPort(s string) string { - var ix int - if strings.Contains(s, "[") && strings.Contains(s, "]") { - //ipv6 : for example : [2606:4700:4700::1111]:443 - - //strip '[' and ']' - s = strings.ReplaceAll(s, "[", "") - s = strings.ReplaceAll(s, "]", "") - - ix = strings.LastIndexAny(s, ":") - if ix == -1 { - return s - } - } else { - //ipv4 - ix = strings.IndexRune(s, ':') - if ix == -1 { - return s - } - + host, _, err := net.SplitHostPort(s) + if err != nil { + return s } - return s[:ix] + return host } -func (proxy *ProxyHttpServer) dial(network, addr string) (c net.Conn, err error) { - if proxy.Tr.Dial != nil { - return proxy.Tr.Dial(network, addr) +func (proxy *ProxyHttpServer) dial(ctx *ProxyCtx, network, addr string) (c net.Conn, err error) { + if ctx.Dialer != nil { + return ctx.Dialer(ctx.Req.Context(), network, addr) + } + + if proxy.Tr != nil && proxy.Tr.DialContext != nil { + return proxy.Tr.DialContext(ctx.Req.Context(), network, addr) } - return net.Dial(network, addr) + + // if the user didn't specify any dialer, we just use the default one, + // provided by net package + var d net.Dialer + return d.DialContext(ctx.Req.Context(), network, addr) } func (proxy *ProxyHttpServer) connectDial(ctx *ProxyCtx, network, addr string) (c net.Conn, err error) { if proxy.ConnectDialWithReq == nil && proxy.ConnectDial == nil { - return proxy.dial(network, addr) + return proxy.dial(ctx, network, addr) } if proxy.ConnectDialWithReq != nil { @@ -101,21 +188,11 @@ var _ halfClosable = (*net.TCPConn)(nil) func (proxy *ProxyHttpServer) handleHttps(w http.ResponseWriter, r *http.Request) { ctx := &ProxyCtx{Req: r, Session: atomic.AddInt64(&proxy.sess, 1), Proxy: proxy, certStore: proxy.CertStore} - hij, ok := w.(http.Hijacker) - if !ok { - panic("httpserver does not support hijacking") - } - - proxyClient, _, e := hij.Hijack() - if e != nil { - panic("Cannot hijack connection " + e.Error()) - } - + // Run CONNECT handlers first, before any connection hijacking ctx.Logf("Running %d CONNECT handlers", len(proxy.httpsHandlers)) todo, host := OkConnect, r.URL.Host for i, h := range proxy.httpsHandlers { newtodo, newhost := h.HandleConnect(host, ctx) - // If found a result, break the loop immediately if newtodo != nil { todo, host = newtodo, newhost @@ -123,9 +200,90 @@ func (proxy *ProxyHttpServer) handleHttps(w http.ResponseWriter, r *http.Request break } } + + hij, canHijack := w.(http.Hijacker) + + // Handle actions that do NOT require a bidirectional tunnel + switch todo.Action { + case ConnectReject: + if ctx.Resp != nil { + if canHijack { + proxyClient, _, e := hij.Hijack() + if e != nil { + ctx.Warnf("Cannot hijack connection: %v", e) + return + } + defer proxyClient.Close() + if err := ctx.Resp.Write(proxyClient); err != nil { + ctx.Warnf("Cannot write response that reject http CONNECT: %v", err) + } + } else { + // HTTP/2: write the rejection as a proper HTTP response. + copyHeaders(w.Header(), ctx.Resp.Header, proxy.KeepDestinationHeaders) + w.WriteHeader(ctx.Resp.StatusCode) + if ctx.Resp.Body != nil { + _, _ = io.Copy(w, ctx.Resp.Body) + _ = ctx.Resp.Body.Close() + } + } + } else if canHijack { + proxyClient, _, _ := hij.Hijack() + _ = proxyClient.Close() + } else { + http.Error(w, "Connection rejected", http.StatusForbidden) + } + return + + case ConnectProxyAuthHijack: + if !canHijack { + // Extended-CONNECT over HTTP/2 does not support 407 hijack flow. + ctx.Warnf("ConnectProxyAuthHijack is not supported when the proxy is served over HTTP/2") + http.Error(w, "Proxy auth hijack not supported in HTTP/2 mode", http.StatusInternalServerError) + return + } + proxyClient, _, e := hij.Hijack() + if e != nil { + ctx.Warnf("Cannot hijack connection: %v", e) + return + } + _, _ = proxyClient.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n")) + todo.Hijack(r, proxyClient, ctx) + return + } + + // All remaining actions need a bidirectional tunnel (proxyClient) + // + // In HTTP/1.1 mode we hijack the connection. + // In HTTP/2 mode (r.ProtoMajor == 2), we take the H2 path as explained in RFC 8441 extended-CONNECT. + var proxyClient net.Conn + isH2Tunnel := false + + if canHijack { + var e error + proxyClient, _, e = hij.Hijack() + if e != nil { + ctx.Warnf("Cannot hijack connection: %v", e) + return + } + } else if r.ProtoMajor == 2 { + // The incoming CONNECT arrived over HTTP/2 (no hijacking available). + // Use h2StreamConn so reads/writes go directly against the H2 stream. + isH2Tunnel = true + // Wrap the H2 stream directly as a net.Conn — no intermediate pipe, + // no goroutines, no unnecessary copies. + proxyClient = newH2StreamConn(w, r) + } else { + // Hijacking is not supported and the request is not HTTP/2. + // This can happen if goproxy is wrapped by middleware that strips the + // Hijacker interface. There is no safe way to tunnel here. + ctx.Warnf("CONNECT: server does not support hijacking and request is not HTTP/2 (proto=%s)", r.Proto) + http.Error(w, "Proxy: cannot establish tunnel (no hijacking support)", http.StatusInternalServerError) + return + } + switch todo.Action { case ConnectAccept: - if !hasPort.MatchString(host) { + if !hasPort(host) { host += ":80" } targetSiteCon, err := proxy.connectDial(ctx, "tcp", host) @@ -135,282 +293,405 @@ func (proxy *ProxyHttpServer) handleHttps(w http.ResponseWriter, r *http.Request return } ctx.Logf("Accepting CONNECT to %s", host) - proxyClient.Write([]byte("HTTP/1.0 200 Connection established\r\n\r\n")) + // In HTTP/1.1 mode the client is waiting for the 200 confirmation; + // In HTTP/2 mode we send it now, after we know the dial succeeded. + if isH2Tunnel { + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } - targetTCP, targetOK := targetSiteCon.(halfClosable) - proxyClientTCP, clientOK := proxyClient.(halfClosable) - if targetOK && clientOK { - go copyAndClose(ctx, targetTCP, proxyClientTCP) - go copyAndClose(ctx, proxyClientTCP, targetTCP) - } else { + // H2 handler must block until the tunnel closes, the HTTP/2 server + // keeps the stream alive only while ServeHTTP is running. + var wg sync.WaitGroup + wg.Add(2) go func() { - var wg sync.WaitGroup - wg.Add(2) - go copyOrWarn(ctx, targetSiteCon, proxyClient, &wg) - go copyOrWarn(ctx, proxyClient, targetSiteCon, &wg) - wg.Wait() - proxyClient.Close() - targetSiteCon.Close() - + defer wg.Done() + err := copyOrWarn(ctx, targetSiteCon, proxyClient) + if err != nil && proxy.ConnectionErrHandler != nil { + proxy.ConnectionErrHandler(proxyClient, ctx, err) + } + _ = targetSiteCon.Close() + // Goroutine 2 may be blocked writing to the H2 stream (flow-control + // stall). Set a past deadline to unblock it immediately. + _ = proxyClient.SetWriteDeadline(time.Now()) + }() + go func() { + defer wg.Done() + _ = copyOrWarn(ctx, proxyClient, targetSiteCon) + // Close r.Body to unblock goroutine 1 if it is still reading from + // the H2 stream. + _ = proxyClient.Close() }() + wg.Wait() + } else { + _, _ = proxyClient.Write([]byte("HTTP/1.0 200 Connection established\r\n\r\n")) + + targetTCP, targetOK := targetSiteCon.(halfClosable) + proxyClientTCP, clientOK := proxyClient.(halfClosable) + if targetOK && clientOK { + go func() { + var wg sync.WaitGroup + wg.Add(2) + go copyAndClose(ctx, targetTCP, proxyClientTCP, &wg) + go copyAndClose(ctx, proxyClientTCP, targetTCP, &wg) + wg.Wait() + // Make sure to close the underlying TCP socket. + // CloseRead() and CloseWrite() keep it open until its timeout, + // causing error when there are thousands of requests. + proxyClientTCP.Close() + targetTCP.Close() + }() + } else { + // There is a race with the runtime here. In the case where the + // connection to the target site times out, we cannot control which + // io.Copy loop will receive the timeout signal first. This means + // that in some cases the error passed to the ConnErrorHandler will + // be the timeout error, and in other cases it will be an error raised + // by the use of a closed network connection. + // + // 2020/05/28 23:42:17 [001] WARN: Error copying to client: read tcp 127.0.0.1:33742->127.0.0.1:34763: i/o timeout + // 2020/05/28 23:42:17 [001] WARN: Error copying to client: read tcp 127.0.0.1:45145->127.0.0.1:60494: use of closed + // network connection + // + // It's also not possible to synchronize these connection closures due to + // TCP connections which are half-closed. When this happens, only the one + // side of the connection breaks out of its io.Copy loop. The other side + // of the connection remains open until it either times out or is reset by + // the client. + go func() { + err := copyOrWarn(ctx, targetSiteCon, proxyClient) + if err != nil && proxy.ConnectionErrHandler != nil { + proxy.ConnectionErrHandler(proxyClient, ctx, err) + } + _ = targetSiteCon.Close() + }() + + go func() { + _ = copyOrWarn(ctx, proxyClient, targetSiteCon) + _ = proxyClient.Close() + }() + } } case ConnectHijack: todo.Hijack(r, proxyClient, ctx) - case ConnectHTTPMitm: - proxyClient.Write([]byte("HTTP/1.0 200 OK\r\n\r\n")) - ctx.Logf("Assuming CONNECT is plain HTTP tunneling, mitm proxying it") - targetSiteCon, err := proxy.connectDial(ctx, "tcp", host) - if err != nil { - ctx.Warnf("Error dialing to %s: %s", host, err.Error()) - return - } - for { - client := bufio.NewReader(proxyClient) - remote := bufio.NewReader(targetSiteCon) - req, err := http.ReadRequest(client) - if err != nil && err != io.EOF { - ctx.Warnf("cannot read request of MITM HTTP client: %+#v", err) - } - if err != nil { - return + + case ConnectHTTPMitm, ConnectMitm: + if isH2Tunnel { + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() } - req, resp := proxy.filterRequest(req, ctx) - if resp == nil { - if err := req.Write(targetSiteCon); err != nil { - httpError(proxyClient, ctx, err) + } else { + _, _ = proxyClient.Write([]byte("HTTP/1.0 200 OK\r\n\r\n")) + } + ctx.Logf("Received CONNECT request, mitm proxying it") + // For HTTP/1.x (after Hijack), the MITM loop runs in a goroutine so the HTTP/1.x + // server is not blocked by the (potentially very long) tunnel and can shut down cleanly. + // For HTTP/2 (isH2Tunnel), the handler must block, the HTTP/2 server keeps the H2 + // stream alive only while ServeHTTP is running; returning early closes the stream. + mitmWork := func() { + // Check if this is an HTTP or an HTTPS MITM request + readBuffer := bufio.NewReader(proxyClient) + peek, _ := readBuffer.Peek(1) + isTLS := len(peek) > 0 && peek[0] == _tlsRecordTypeHandshake + + var client net.Conn = &readBufferedConn{Conn: proxyClient, r: readBuffer} + defer func() { + _ = client.Close() + }() + + if isTLS { + tlsConfig := defaultTLSConfig + if todo.TLSConfig != nil { + var err error + tlsConfig, err = todo.TLSConfig(host, ctx) + if err != nil { + httpError(proxyClient, ctx, err) + return + } + } + tlsConfig = tlsConfig.Clone() + + if proxy.AllowHTTP2 { + if !slices.Contains(tlsConfig.NextProtos, "h2") { + tlsConfig.NextProtos = append(tlsConfig.NextProtos, "h2") + } + } + + if !slices.Contains(tlsConfig.NextProtos, "http/1.1") { + tlsConfig.NextProtos = append(tlsConfig.NextProtos, "http/1.1") + } + + // Create a TLS connection over the TCP connection + rawClientTls := tls.Server(client, tlsConfig) + client = rawClientTls + if err := rawClientTls.HandshakeContext(context.Background()); err != nil { + ctx.Warnf("Cannot handshake client %v %v", r.Host, err) return } - resp, err = http.ReadResponse(remote, req) - if err != nil { - httpError(proxyClient, ctx, err) + if proxy.AllowHTTP2 && rawClientTls.ConnectionState().NegotiatedProtocol == "h2" { + ctx.Logf("ALPN negotiated h2, starting http2.ServeConn") + proxy.serveH2Mitm(client, host, ctx) + return + } + } else if proxy.AllowHTTP2 { + // Handle cleartext HTTP/2 (h2c) by looking for the client preface. + preface, err := readBuffer.Peek(len(http2.ClientPreface)) + if err == nil && string(preface) == http2.ClientPreface { + proxy.serveH2Mitm(client, host, ctx) return } - defer resp.Body.Close() - } - resp = proxy.filterResponse(resp, ctx) - if err := resp.Write(proxyClient); err != nil { - httpError(proxyClient, ctx, err) - return - } - } - case ConnectMitm: - proxyClient.Write([]byte("HTTP/1.0 200 OK\r\n\r\n")) - ctx.Logf("Assuming CONNECT is TLS, mitm proxying it") - // this goes in a separate goroutine, so that the net/http server won't think we're - // still handling the request even after hijacking the connection. Those HTTP CONNECT - // request can take forever, and the server will be stuck when "closed". - // TODO: Allow Server.Close() mechanism to shut down this connection as nicely as possible - tlsConfig := defaultTLSConfig - if todo.TLSConfig != nil { - var err error - tlsConfig, err = todo.TLSConfig(host, ctx) - if err != nil { - httpError(proxyClient, ctx, err) - return } - } - go func() { - //TODO: cache connections to the remote website - rawClientTls := tls.Server(proxyClient, tlsConfig) - defer rawClientTls.Close() - if err := rawClientTls.Handshake(); err != nil { - ctx.Warnf("Cannot handshake client %v %v", r.Host, err) - return + + scheme := "http" + if isTLS { + scheme = "https" } - clientTlsReader := bufio.NewReader(rawClientTls) - for !isEof(clientTlsReader) { - req, err := http.ReadRequest(clientTlsReader) - var ctx = &ProxyCtx{Req: req, Session: atomic.AddInt64(&proxy.sess, 1), Proxy: proxy, UserData: ctx.UserData} - if err != nil && err != io.EOF { - return + + clientReader := http1parser.NewRequestReader(proxy.PreventCanonicalization, client) + for !clientReader.IsEOF() { + req, err := clientReader.ReadRequest() + ctx := &ProxyCtx{ + Req: req, + Session: atomic.AddInt64(&proxy.sess, 1), + Proxy: proxy, + UserData: ctx.UserData, + RoundTripper: ctx.RoundTripper, + } + if err != nil && !errors.Is(err, io.EOF) { + ctx.Warnf("Cannot read request from mitm'd client %v %v", r.Host, err) } if err != nil { - ctx.Warnf("Cannot read TLS request from mitm'd client %v %v", r.Host, err) return } - req.RemoteAddr = r.RemoteAddr // since we're converting the request, need to carry over the original connecting IP as well + + // since we're converting the request, need to carry over the + // original connecting IP as well + req.RemoteAddr = r.RemoteAddr ctx.Logf("req %v", r.Host) - if !httpsRegexp.MatchString(req.URL.String()) { - req.URL, err = url.Parse("https://" + r.Host + req.URL.String()) + if !req.URL.IsAbs() { + // Origin-form request target (/path) + req.URL, err = url.Parse(scheme + "://" + r.Host + req.URL.String()) + } else { + // Absolute-form request target + req.URL.Scheme = scheme } - // Bug fix which goproxy fails to provide request - // information URL in the context when does HTTPS MITM - ctx.Req = req + if continueLoop := func(req *http.Request) bool { + // Since we handled the request parsing by our own, we manually + // need to set a cancellable context when we finished the request + // processing (same behaviour of the stdlib) + requestContext, finishRequest := context.WithCancel(req.Context()) + req = req.WithContext(requestContext) + defer finishRequest() + + // explicitly discard request body to avoid data races in certain RoundTripper implementations + // see https://github.com/golang/go/issues/61596#issuecomment-1652345131 + defer req.Body.Close() - req, resp := proxy.filterRequest(req, ctx) - if resp == nil { - if req.Method == "PRI" { - // Handle HTTP/2 connections. + // Bug fix which goproxy fails to provide request + // information URL in the context when does HTTPS MITM + ctx.Req = req - // NOTE: As of 1.22, golang's http module will not recognize or - // parse the HTTP Body for PRI requests. This leaves the body of - // the http2.ClientPreface ("SM\r\n\r\n") on the wire which we need - // to clear before setting up the connection. - _, err := clientTlsReader.Discard(6) + req, resp := proxy.filterRequest(req, ctx) + if resp == nil { if err != nil { - ctx.Warnf("Failed to process HTTP2 client preface: %v", err) - return + if req.URL != nil { + ctx.Warnf("Illegal URL %s", scheme+"://"+r.Host+req.URL.Path) + } else { + ctx.Warnf("Illegal URL %s", scheme+"://"+r.Host) + } + return false } - if !proxy.AllowHTTP2 { - ctx.Warnf("HTTP2 connection failed: disallowed") - return + if !proxy.KeepHeader { + RemoveProxyHeaders(ctx, req) } - tr := H2Transport{clientTlsReader, rawClientTls, tlsConfig.Clone(), host} - if _, err := tr.RoundTrip(req); err != nil { - ctx.Warnf("HTTP2 connection failed: %v", err) - } else { - ctx.Logf("Exiting on EOF") + resp, err = ctx.RoundTrip(req) + if err != nil { + ctx.Warnf("Cannot read response from mitm'd server %v", err) + return false } - return + ctx.Logf("resp %v", resp.Status) } - if isWebSocketRequest(req) { - ctx.Logf("Request looks like websocket upgrade.") - proxy.serveWebsocketTLS(ctx, w, req, tlsConfig, rawClientTls) - return + origBody := resp.Body + resp = proxy.filterResponse(resp, ctx) + bodyModified := resp.Body != origBody + defer resp.Body.Close() + if resp.Body != http.NoBody && (bodyModified || + (resp.ContentLength <= 0 && resp.Header.Get("Content-Length") == "")) { + // Return chunked encoded response when we don't know the length of the resp, if the body + // has been modified by the response handler or if there is no content length in the response. + // We include 0 in resp.ContentLength <= 0 because 0 is the field zero value and some user + // might incorrectly leave it instead of setting it to -1 when the length is unknown (but we + // also check that the Content-Length header is empty, so there is no issue with empty bodies). + resp.ContentLength = -1 + resp.Header.Del("Content-Length") + resp.TransferEncoding = []string{"chunked"} } - if err != nil { - if req.URL != nil { - ctx.Warnf("Illegal URL %s", "https://"+r.Host+req.URL.Path) - } else { - ctx.Warnf("Illegal URL %s", "https://"+r.Host) + + // The MITM'd client speaks HTTP/1.1, but the upstream + // response may have been received over HTTP/2. Normalize + // the protocol version so resp.Write() produces a valid + // HTTP/1.1 status line. + resp.Proto = "HTTP/1.1" + resp.ProtoMajor = 1 + resp.ProtoMinor = 1 + + if isWebSocketHandshake(resp.Header) { + ctx.Logf("Response looks like websocket upgrade.") + + // According to resp.Body documentation: + // As of Go 1.12, the Body will also implement io.Writer + // on a successful "101 Switching Protocols" response, + // as used by WebSockets and HTTP/2's "h2c" mode. + wsConn, ok := resp.Body.(io.ReadWriter) + if !ok { + ctx.Warnf("Unable to use Websocket connection") + return false } - return + // Set Body to nil so resp.Write only writes the headers + // and returns immediately without blocking on the body + // (or else we wouldn't be able to proxy WebSocket data). + resp.Body = nil + // Buffer the head so it ships as one TLS record, not one tiny record per header (trips strict clients). + bw := bufio.NewWriter(client) + if err := resp.Write(bw); err != nil { + ctx.Warnf("Cannot write response header from mitm'd client: %v", err) + return false + } + if err := bw.Flush(); err != nil { + ctx.Warnf("Cannot flush response header from mitm'd client: %v", err) + return false + } + proxy.proxyWebsocket(ctx, wsConn, client) + return false } - removeProxyHeaders(ctx, req) - resp, err = func() (*http.Response, error) { - // explicitly discard request body to avoid data races in certain RoundTripper implementations - // see https://github.com/golang/go/issues/61596#issuecomment-1652345131 - defer req.Body.Close() - return ctx.RoundTrip(req) - }() - if err != nil { - ctx.Warnf("Cannot read TLS response from mitm'd server %v", err) - return + + writer := &responseHeadWriter{writer: client} + if err := resp.Write(writer); err != nil { + ctx.Warnf("Cannot write response from mitm'd client: %v", err) + return false } - ctx.Logf("resp %v", resp.Status) - } - resp = proxy.filterResponse(resp, ctx) - defer resp.Body.Close() - text := resp.Status - statusCode := strconv.Itoa(resp.StatusCode) + " " - if strings.HasPrefix(text, statusCode) { - text = text[len(statusCode):] - } - // always use 1.1 to support chunked encoding - if _, err := io.WriteString(rawClientTls, "HTTP/1.1"+" "+statusCode+text+"\r\n"); err != nil { - ctx.Warnf("Cannot write TLS response HTTP status from mitm'd client: %v", err) + return true + }(req); !continueLoop { return } - - if resp.Request.Method == "HEAD" { - // don't change Content-Length for HEAD request - } else { - // Since we don't know the length of resp, return chunked encoded response - // TODO: use a more reasonable scheme - resp.Header.Del("Content-Length") - resp.Header.Set("Transfer-Encoding", "chunked") - } - // Force connection close otherwise chrome will keep CONNECT tunnel open forever - resp.Header.Set("Connection", "close") - if err := resp.Header.Write(rawClientTls); err != nil { - ctx.Warnf("Cannot write TLS response header from mitm'd client: %v", err) - return - } - if _, err = io.WriteString(rawClientTls, "\r\n"); err != nil { - ctx.Warnf("Cannot write TLS response header end from mitm'd client: %v", err) - return - } - - if resp.Request.Method == "HEAD" { - // Don't write out a response body for HEAD request - } else { - chunked := newChunkedWriter(rawClientTls) - if _, err := io.Copy(chunked, resp.Body); err != nil { - ctx.Warnf("Cannot write TLS response body from mitm'd client: %v", err) - return - } - if err := chunked.Close(); err != nil { - ctx.Warnf("Cannot write TLS chunked EOF from mitm'd client: %v", err) - return - } - if _, err = io.WriteString(rawClientTls, "\r\n"); err != nil { - ctx.Warnf("Cannot write TLS response chunked trailer from mitm'd client: %v", err) - return - } - } } ctx.Logf("Exiting on EOF") - }() - case ConnectProxyAuthHijack: - proxyClient.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n")) - todo.Hijack(r, proxyClient, ctx) - case ConnectReject: - if ctx.Resp != nil { - if err := ctx.Resp.Write(proxyClient); err != nil { - ctx.Warnf("Cannot write response that reject http CONNECT: %v", err) - } } - proxyClient.Close() + if isH2Tunnel { + mitmWork() + } else { + go mitmWork() + } } } -func httpError(w io.WriteCloser, ctx *ProxyCtx, err error) { - errStr := fmt.Sprintf("HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n%s", len(err.Error()), err.Error()) - if _, err := io.WriteString(w, errStr); err != nil { - ctx.Warnf("Error responding to client: %s", err) +func httpError(w io.Writer, ctx *ProxyCtx, err error) { + if ctx.Proxy.ConnectionErrHandler != nil { + ctx.Proxy.ConnectionErrHandler(w, ctx, err) + } else { + var rw http.ResponseWriter + if r, ok := w.(http.ResponseWriter); ok { + rw = r + } else if h2, ok := w.(responseWriterProvider); ok { + rw = h2.ResponseWriter() + } + + if rw != nil { + http.Error(rw, err.Error(), http.StatusBadGateway) + } else { + errorMessage := err.Error() + errStr := fmt.Sprintf( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n%s", + len(errorMessage), + errorMessage, + ) + if _, err := io.WriteString(w, errStr); err != nil { + ctx.Warnf("Error responding to client: %s", err) + } + } } - if err := w.Close(); err != nil { - ctx.Warnf("Error closing client connection: %s", err) + if c, ok := w.(io.Closer); ok { + if err := c.Close(); err != nil { + ctx.Warnf("Error closing client connection: %s", err) + } } } -func copyOrWarn(ctx *ProxyCtx, dst io.Writer, src io.Reader, wg *sync.WaitGroup) { - if _, err := io.Copy(dst, src); err != nil { +func copyOrWarn(ctx *ProxyCtx, dst io.Writer, src io.Reader) error { + _, err := io.Copy(dst, src) + if err != nil && errors.Is(err, net.ErrClosed) { + // Discard closed connection errors + err = nil + } else if err != nil { ctx.Warnf("Error copying to client: %s", err) } - wg.Done() + return err } -func copyAndClose(ctx *ProxyCtx, dst, src halfClosable) { - if _, err := io.Copy(dst, src); err != nil { - ctx.Warnf("Error copying to client: %s", err) +func copyAndClose(ctx *ProxyCtx, dst, src halfClosable, wg *sync.WaitGroup) { + _, err := io.Copy(dst, src) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + ctx.Warnf("Error copying to client: %s", err.Error()) + } + // Fully close dst to unblock any goroutine blocked on + // io.Copy reading from it. Half-close (CloseWrite/CloseRead) + // would not interrupt a pending read, leaving the other + // goroutine stuck and the client connection never closed. + _ = dst.Close() + _ = src.Close() + } else { + _ = dst.CloseWrite() + _ = src.CloseRead() } - - dst.CloseWrite() - src.CloseRead() + wg.Done() } func dialerFromEnv(proxy *ProxyHttpServer) func(network, addr string) (net.Conn, error) { - https_proxy := os.Getenv("HTTPS_PROXY") - if https_proxy == "" { - https_proxy = os.Getenv("https_proxy") + httpsProxy := os.Getenv("HTTPS_PROXY") + if httpsProxy == "" { + httpsProxy = os.Getenv("https_proxy") } - if https_proxy == "" { + if httpsProxy == "" { return nil } - return proxy.NewConnectDialToProxy(https_proxy) + return proxy.NewConnectDialToProxy(httpsProxy) } -func (proxy *ProxyHttpServer) NewConnectDialToProxy(https_proxy string) func(network, addr string) (net.Conn, error) { - return proxy.NewConnectDialToProxyWithHandler(https_proxy, nil) +// NewConnectDialToProxy returns a dial function that establishes TCP connections +// through an upstream HTTP/HTTPS proxy using the CONNECT method. +// Use it to set proxy.ConnectDial when chaining two proxy servers. +// For authentication or other CONNECT request modifications, use NewConnectDialToProxyWithHandler instead. +func (proxy *ProxyHttpServer) NewConnectDialToProxy(httpsProxy string) func(network, addr string) (net.Conn, error) { + return proxy.NewConnectDialToProxyWithHandler(httpsProxy, nil) } -func (proxy *ProxyHttpServer) NewConnectDialToProxyWithHandler(https_proxy string, connectReqHandler func(req *http.Request)) func(network, addr string) (net.Conn, error) { - u, err := url.Parse(https_proxy) +// NewConnectDialToProxyWithHandler returns a dial function that establishes TCP connections +// through an upstream HTTP/HTTPS proxy using the CONNECT method, calling connectReqHandler +// before sending the CONNECT request. Use connectReqHandler to add headers such as +// Proxy-Authorization to authenticate with the upstream proxy. +// If connectReqHandler is nil, the behavior is identical to NewConnectDialToProxy. +func (proxy *ProxyHttpServer) NewConnectDialToProxyWithHandler( + httpsProxy string, + connectReqHandler func(req *http.Request), +) func(network, addr string) (net.Conn, error) { + u, err := url.Parse(httpsProxy) if err != nil { return nil } - if u.Scheme == "" || u.Scheme == "http" { + if u.Scheme == "" || u.Scheme == "http" || u.Scheme == "ws" { if !strings.ContainsRune(u.Host, ':') { u.Host += ":80" } return func(network, addr string) (net.Conn, error) { connectReq := &http.Request{ - Method: "CONNECT", + Method: http.MethodConnect, URL: &url.URL{Opaque: addr}, Host: addr, Header: make(http.Header), @@ -418,27 +699,27 @@ func (proxy *ProxyHttpServer) NewConnectDialToProxyWithHandler(https_proxy strin if connectReqHandler != nil { connectReqHandler(connectReq) } - c, err := proxy.dial(network, u.Host) + c, err := proxy.dial(&ProxyCtx{Req: &http.Request{}}, network, u.Host) if err != nil { return nil, err } - connectReq.Write(c) + _ = connectReq.Write(c) // Read response. // Okay to use and discard buffered reader here, because // TLS server will not speak until spoken to. br := bufio.NewReader(c) resp, err := http.ReadResponse(br, connectReq) if err != nil { - c.Close() + _ = c.Close() return nil, err } defer resp.Body.Close() - if resp.StatusCode != 200 { - resp, err := ioutil.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + resp, err := io.ReadAll(io.LimitReader(resp.Body, _errorRespMaxLength)) if err != nil { return nil, err } - c.Close() + _ = c.Close() return nil, errors.New("proxy refused connection" + string(resp)) } return c, nil @@ -449,13 +730,19 @@ func (proxy *ProxyHttpServer) NewConnectDialToProxyWithHandler(https_proxy strin u.Host += ":443" } return func(network, addr string) (net.Conn, error) { - c, err := proxy.dial(network, u.Host) + ctx := &ProxyCtx{Req: &http.Request{}} + c, err := proxy.dial(ctx, network, u.Host) if err != nil { return nil, err } - c = tls.Client(c, proxy.Tr.TLSClientConfig) + + c, err = proxy.initializeTLSconnection(ctx, c, proxy.Tr.TLSClientConfig, u.Host) + if err != nil { + return nil, err + } + connectReq := &http.Request{ - Method: "CONNECT", + Method: http.MethodConnect, URL: &url.URL{Opaque: addr}, Host: addr, Header: make(http.Header), @@ -463,23 +750,23 @@ func (proxy *ProxyHttpServer) NewConnectDialToProxyWithHandler(https_proxy strin if connectReqHandler != nil { connectReqHandler(connectReq) } - connectReq.Write(c) + _ = connectReq.Write(c) // Read response. // Okay to use and discard buffered reader here, because // TLS server will not speak until spoken to. br := bufio.NewReader(c) resp, err := http.ReadResponse(br, connectReq) if err != nil { - c.Close() + _ = c.Close() return nil, err } defer resp.Body.Close() - if resp.StatusCode != 200 { - body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 500)) + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(io.LimitReader(resp.Body, _errorRespMaxLength)) if err != nil { return nil, err } - c.Close() + _ = c.Close() return nil, errors.New("proxy refused connection" + string(body)) } return c, nil @@ -488,6 +775,10 @@ func (proxy *ProxyHttpServer) NewConnectDialToProxyWithHandler(https_proxy strin return nil } +// TLSConfigFromCA returns a TLSConfig function that generates dynamic TLS certificates +// for each target host, signed by the given CA certificate. +// The generated certificates are used during MITM interception (ConnectMitm). +// If a CertStorage is set on the ProxyCtx, certificates are cached and reused to save CPU. func TLSConfigFromCA(ca *tls.Certificate) func(host string, ctx *ProxyCtx) (*tls.Config, error) { return func(host string, ctx *ProxyCtx) (*tls.Config, error) { var err error @@ -498,7 +789,7 @@ func TLSConfigFromCA(ca *tls.Certificate) func(host string, ctx *ProxyCtx) (*tls ctx.Logf("signing for %s", stripPort(host)) genCert := func() (*tls.Certificate, error) { - return signHost(*ca, []string{hostname}) + return signer.SignHost(*ca, []string{hostname}) } if ctx.certStore != nil { cert, err = ctx.certStore.Fetch(hostname, genCert) @@ -515,3 +806,29 @@ func TLSConfigFromCA(ca *tls.Certificate) func(host string, ctx *ProxyCtx) (*tls return config, nil } } + +func (proxy *ProxyHttpServer) initializeTLSconnection( + ctx *ProxyCtx, + targetConn net.Conn, + tlsConfig *tls.Config, + addr string, +) (net.Conn, error) { + // Infer target ServerName, it's a copy of implementation inside tls.Dial() + if tlsConfig.ServerName == "" { + colonPos := strings.LastIndex(addr, ":") + if colonPos == -1 { + colonPos = len(addr) + } + hostname := addr[:colonPos] + // Make a copy to avoid polluting argument or default. + c := tlsConfig.Clone() + c.ServerName = hostname + tlsConfig = c + } + + tlsConn := tls.Client(targetConn, tlsConfig) + if err := tlsConn.HandshakeContext(ctx.Req.Context()); err != nil { + return nil, err + } + return tlsConn, nil +} diff --git a/vendor/github.com/elazarl/goproxy/internal/http1parser/header.go b/vendor/github.com/elazarl/goproxy/internal/http1parser/header.go new file mode 100644 index 0000000..d4ef3e6 --- /dev/null +++ b/vendor/github.com/elazarl/goproxy/internal/http1parser/header.go @@ -0,0 +1,43 @@ +package http1parser + +import ( + "errors" + "net/textproto" + "strings" +) + +var ErrBadProto = errors.New("bad protocol") + +// Http1ExtractHeaders is an HTTP/1.0 and HTTP/1.1 header-only parser, +// to extract the original header names for the received request. +// Fully inspired by readMIMEHeader() in +// https://github.com/golang/go/blob/master/src/net/textproto/reader.go +func Http1ExtractHeaders(r *textproto.Reader) ([]string, error) { + // Discard first line, it doesn't contain useful information, and it has + // already been validated in http.ReadRequest() + if _, err := r.ReadLine(); err != nil { + return nil, err + } + + // The first line cannot start with a leading space. + if buf, err := r.R.Peek(1); err == nil && (buf[0] == ' ' || buf[0] == '\t') { + return nil, ErrBadProto + } + + var headerNames []string + for { + kv, err := r.ReadContinuedLine() + if len(kv) == 0 { + // We have finished to parse the headers if we receive empty + // data without an error + return headerNames, err + } + + // Key ends at first colon. + k, _, ok := strings.Cut(kv, ":") + if !ok { + return nil, ErrBadProto + } + headerNames = append(headerNames, k) + } +} diff --git a/vendor/github.com/elazarl/goproxy/internal/http1parser/request.go b/vendor/github.com/elazarl/goproxy/internal/http1parser/request.go new file mode 100644 index 0000000..1f4d76b --- /dev/null +++ b/vendor/github.com/elazarl/goproxy/internal/http1parser/request.go @@ -0,0 +1,93 @@ +package http1parser + +import ( + "bufio" + "bytes" + "errors" + "io" + "net/http" + "net/textproto" +) + +type RequestReader struct { + preventCanonicalization bool + reader *bufio.Reader + // Used only when preventCanonicalization value is true + cloned *bytes.Buffer +} + +func NewRequestReader(preventCanonicalization bool, conn io.Reader) *RequestReader { + if !preventCanonicalization { + return &RequestReader{ + preventCanonicalization: false, + reader: bufio.NewReader(conn), + } + } + + var cloned bytes.Buffer + reader := bufio.NewReader(io.TeeReader(conn, &cloned)) + return &RequestReader{ + preventCanonicalization: true, + reader: reader, + cloned: &cloned, + } +} + +// IsEOF returns true if there is no more data that can be read from the +// buffer and the underlying connection is closed. +func (r *RequestReader) IsEOF() bool { + _, err := r.reader.Peek(1) + return errors.Is(err, io.EOF) +} + +// Reader is used to take over the buffered connection data. +// After calling this function, make sure to consume all the data related +// to the current request. +func (r *RequestReader) Reader() *bufio.Reader { + return r.reader +} + +func (r *RequestReader) ReadRequest() (*http.Request, error) { + if !r.preventCanonicalization { + // Just call the HTTP library function if the preventCanonicalization + // configuration is disabled + return http.ReadRequest(r.reader) + } + + req, err := http.ReadRequest(r.reader) + if err != nil { + return nil, err + } + + httpDataReader := getRequestReader(r.reader, r.cloned) + headers, _ := Http1ExtractHeaders(httpDataReader) + + for _, headerName := range headers { + canonicalizedName := textproto.CanonicalMIMEHeaderKey(headerName) + if canonicalizedName == headerName { + continue + } + + // Rewrite header keys to the non-canonical parsed value + values, ok := req.Header[canonicalizedName] + if ok { + req.Header.Del(canonicalizedName) + req.Header[headerName] = values + } + } + + return req, nil +} + +func getRequestReader(r *bufio.Reader, cloned *bytes.Buffer) *textproto.Reader { + // "Cloned" buffer uses the raw connection as the data source. + // However, the *bufio.Reader can read also bytes of another unrelated + // request on the same connection, since it's buffered, so we have to + // ignore them before passing the data to our headers parser. + // Data related to the next request will remain inside the buffer for + // later usage. + data := cloned.Next(cloned.Len() - r.Buffered()) + return &textproto.Reader{ + R: bufio.NewReader(bytes.NewReader(data)), + } +} diff --git a/vendor/github.com/elazarl/goproxy/counterecryptor.go b/vendor/github.com/elazarl/goproxy/internal/signer/counterecryptor.go similarity index 79% rename from vendor/github.com/elazarl/goproxy/counterecryptor.go rename to vendor/github.com/elazarl/goproxy/internal/signer/counterecryptor.go index d1c39d2..acb9925 100644 --- a/vendor/github.com/elazarl/goproxy/counterecryptor.go +++ b/vendor/github.com/elazarl/goproxy/internal/signer/counterecryptor.go @@ -1,9 +1,10 @@ -package goproxy +package signer import ( "crypto/aes" "crypto/cipher" "crypto/ecdsa" + "crypto/ed25519" "crypto/rsa" "crypto/sha256" "crypto/x509" @@ -17,7 +18,7 @@ type CounterEncryptorRand struct { ix int } -func NewCounterEncryptorRandFromKey(key interface{}, seed []byte) (r CounterEncryptorRand, err error) { +func NewCounterEncryptorRandFromKey(key any, seed []byte) (r CounterEncryptorRand, err error) { var keyBytes []byte switch key := key.(type) { case *rsa.PrivateKey: @@ -26,13 +27,16 @@ func NewCounterEncryptorRandFromKey(key interface{}, seed []byte) (r CounterEncr if keyBytes, err = x509.MarshalECPrivateKey(key); err != nil { return } + case ed25519.PrivateKey: + if keyBytes, err = x509.MarshalPKCS8PrivateKey(key); err != nil { + return + } default: - err = errors.New("only RSA and ECDSA keys supported") - return + return r, errors.New("only RSA, ED25519 and ECDSA keys supported") } h := sha256.New() if r.cipher, err = aes.NewCipher(h.Sum(keyBytes)[:aes.BlockSize]); err != nil { - return + return r, err } r.counter = make([]byte, r.cipher.BlockSize()) if seed != nil { @@ -40,7 +44,7 @@ func NewCounterEncryptorRandFromKey(key interface{}, seed []byte) (r CounterEncr } r.rand = make([]byte, r.cipher.BlockSize()) r.ix = len(r.rand) - return + return r, nil } func (c *CounterEncryptorRand) Seed(b []byte) { diff --git a/vendor/github.com/elazarl/goproxy/internal/signer/signer.go b/vendor/github.com/elazarl/goproxy/internal/signer/signer.go new file mode 100644 index 0000000..d62ec1a --- /dev/null +++ b/vendor/github.com/elazarl/goproxy/internal/signer/signer.go @@ -0,0 +1,122 @@ +package signer + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "math/rand" + "net" + "runtime" + "sort" + "strings" + "time" +) + +const _goproxySignerVersion = ":goproxy2" + +func hashSorted(lst []string) []byte { + c := make([]string, len(lst)) + copy(c, lst) + sort.Strings(c) + h := sha256.New() + h.Write([]byte(strings.Join(c, ","))) + return h.Sum(nil) +} + +func SignHost(ca tls.Certificate, hosts []string) (cert *tls.Certificate, err error) { + // Use the provided CA for certificate generation. + // Use already parsed Leaf certificate when present. + x509ca := ca.Leaf + if x509ca == nil { + if x509ca, err = x509.ParseCertificate(ca.Certificate[0]); err != nil { + return nil, err + } + } + + now := time.Now() + start := now.Add(-30 * 24 * time.Hour) // -30 days + end := now.Add(365 * 24 * time.Hour) // 365 days + + // Always generate a positive int value + // (Two complement is not enabled when the first bit is 0) + generated := rand.Uint64() + generated >>= 1 + + template := x509.Certificate{ + SerialNumber: big.NewInt(int64(generated)), + Issuer: x509ca.Subject, + Subject: pkix.Name{ + Organization: []string{"GoProxy untrusted MITM proxy Inc"}, + }, + NotBefore: start, + NotAfter: end, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + template.Subject.CommonName = h + } + } + + hash := hashSorted(append(hosts, _goproxySignerVersion, ":"+runtime.Version())) + var csprng CounterEncryptorRand + if csprng, err = NewCounterEncryptorRandFromKey(ca.PrivateKey, hash); err != nil { + return nil, err + } + + var certpriv crypto.Signer + switch ca.PrivateKey.(type) { + case *rsa.PrivateKey: + if certpriv, err = rsa.GenerateKey(&csprng, 2048); err != nil { + return nil, err + } + case *ecdsa.PrivateKey: + if certpriv, err = ecdsa.GenerateKey(elliptic.P256(), &csprng); err != nil { + return nil, err + } + case ed25519.PrivateKey: + if _, certpriv, err = ed25519.GenerateKey(&csprng); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported key type %T", ca.PrivateKey) + } + + derBytes, err := x509.CreateCertificate(&csprng, &template, x509ca, certpriv.Public(), ca.PrivateKey) + if err != nil { + return nil, err + } + + // Save an already parsed leaf certificate to use less CPU + // when it will be used + leafCert, err := x509.ParseCertificate(derBytes) + if err != nil { + return nil, err + } + + certBytes := make([][]byte, 1+len(ca.Certificate)) + certBytes[0] = derBytes + for i, singleCertBytes := range ca.Certificate { + certBytes[i+1] = singleCertBytes + } + + return &tls.Certificate{ + Certificate: certBytes, + PrivateKey: certpriv, + Leaf: leafCert, + }, nil +} diff --git a/vendor/github.com/elazarl/goproxy/logger.go b/vendor/github.com/elazarl/goproxy/logger.go index 939cf69..592c4e4 100644 --- a/vendor/github.com/elazarl/goproxy/logger.go +++ b/vendor/github.com/elazarl/goproxy/logger.go @@ -1,5 +1,9 @@ package goproxy +// Logger is the interface used by ProxyHttpServer to emit log messages. +// Any type implementing Printf with the standard fmt.Sprintf signature satisfies this interface. +// By default, NewProxyHttpServer sets Logger to log.New(os.Stderr, "", log.LstdFlags). +// Log output is emitted only when ProxyHttpServer.Verbose is set to true. type Logger interface { - Printf(format string, v ...interface{}) + Printf(format string, v ...any) } diff --git a/vendor/github.com/elazarl/goproxy/proxy.go b/vendor/github.com/elazarl/goproxy/proxy.go index 3deecfb..50fad0a 100644 --- a/vendor/github.com/elazarl/goproxy/proxy.go +++ b/vendor/github.com/elazarl/goproxy/proxy.go @@ -1,14 +1,13 @@ package goproxy import ( - "bufio" "io" "log" "net" "net/http" "os" - "regexp" - "sync/atomic" + + "golang.org/x/net/http2" ) // The basic proxy type. Implements http.Handler. @@ -19,49 +18,83 @@ type ProxyHttpServer struct { // KeepDestinationHeaders indicates the proxy should retain any headers present in the http.Response before proxying KeepDestinationHeaders bool // setting Verbose to true will log information on each request sent to the proxy - Verbose bool - Logger Logger + Verbose bool + // Logger is used to emit log messages. Defaults to log.New(os.Stderr, "", log.LstdFlags). + // Log output is only produced when Verbose is true. + // Any type implementing Printf satisfies the Logger interface. + Logger Logger + // NonproxyHandler is invoked for requests that are not proxy requests, + // i.e. requests with a relative path (e.g. GET /ping) instead of an absolute URL. + // Defaults to a handler that returns HTTP 500 with an explanatory message. NonproxyHandler http.Handler reqHandlers []ReqHandler respHandlers []RespHandler httpsHandlers []HttpsHandler - Tr *http.Transport + // Tr is the http.Transport used to send requests to destination servers. + // Defaults to a transport that skips TLS verification and reads proxy settings from environment variables. + Tr *http.Transport + // ConnectionErrHandler will be invoked to return a custom response + // to clients (written using conn parameter), when goproxy fails to connect + // to a target proxy. + // The error is passed as function parameter and not inside the proxy + // context, to avoid race conditions. + ConnectionErrHandler func(conn io.Writer, ctx *ProxyCtx, err error) // ConnectDial will be used to create TCP connections for CONNECT requests // if nil Tr.Dial will be used - ConnectDial func(network string, addr string) (net.Conn, error) + ConnectDial func(network string, addr string) (net.Conn, error) + // ConnectDialWithReq is like ConnectDial but also receives the original CONNECT request, + // allowing dial decisions based on request headers (e.g. target host, auth tokens). + // When both ConnectDialWithReq and ConnectDial are set, ConnectDialWithReq takes precedence. ConnectDialWithReq func(req *http.Request, network string, addr string) (net.Conn, error) - CertStore CertStorage - KeepHeader bool - AllowHTTP2 bool + // CertStore is an optional cache for MITM certificates. When set, the proxy reuses + // previously generated TLS certificates for the same hostname, avoiding repeated + // CPU-intensive signing operations. Strongly recommended for production use. + CertStore CertStorage + // KeepHeader, when true, preserves the Proxy-Authorization header when forwarding + // requests to an upstream proxy. By default this header is stripped. + KeepHeader bool + // AllowHTTP2, when true, enables HTTP/2 support in the proxy. Disabled by default. + AllowHTTP2 bool + // When PreventCanonicalization is true, the header names present in + // the request sent through the proxy are directly passed to the destination server, + // instead of following the HTTP RFC for their canonicalization. + // This is useful when the header name isn't treated as a case-insensitive + // value by the target server, because they don't follow the specs. + PreventCanonicalization bool + // KeepAcceptEncoding, if true, prevents the proxy from dropping + // Accept-Encoding headers from the client. + // + // Note that the outbound http.Transport may still choose to add + // Accept-Encoding: gzip if the client did not explicitly send an + // Accept-Encoding header. To disable this behavior, set + // Tr.DisableCompression to true. + KeepAcceptEncoding bool + // h2Server is the HTTP/2 server instance used for MITM. + // It is shared across all connections. + h2Server *http2.Server } -var hasPort = regexp.MustCompile(`:\d+$`) +func hasPort(s string) bool { + _, _, err := net.SplitHostPort(s) + return err == nil +} func copyHeaders(dst, src http.Header, keepDestHeaders bool) { if !keepDestHeaders { for k := range dst { - dst.Del(k) + delete(dst, k) } } for k, vs := range src { - for _, v := range vs { - dst.Add(k, v) - } + // direct assignment to avoid canonicalization + dst[k] = append(dst[k], vs...) } } -func isEof(r *bufio.Reader) bool { - _, err := r.Peek(1) - if err == io.EOF { - return true - } - return false -} - func (proxy *ProxyHttpServer) filterRequest(r *http.Request, ctx *ProxyCtx) (req *http.Request, resp *http.Response) { req = r for _, h := range proxy.reqHandlers { - req, resp = h.Handle(r, ctx) + req, resp = h.Handle(req, ctx) // non-nil resp means the handler decided to skip sending the request // and return canned response instead. if resp != nil { @@ -70,6 +103,7 @@ func (proxy *ProxyHttpServer) filterRequest(r *http.Request, ctx *ProxyCtx) (req } return } + func (proxy *ProxyHttpServer) filterResponse(respOrig *http.Response, ctx *ProxyCtx) (resp *http.Response) { resp = respOrig for _, h := range proxy.respHandlers { @@ -79,12 +113,15 @@ func (proxy *ProxyHttpServer) filterResponse(respOrig *http.Response, ctx *Proxy return } -func removeProxyHeaders(ctx *ProxyCtx, r *http.Request) { +// RemoveProxyHeaders removes all proxy headers which should not propagate to the next hop. +func RemoveProxyHeaders(ctx *ProxyCtx, r *http.Request) { r.RequestURI = "" // this must be reset when serving a request with the client ctx.Logf("Sending request %v %v", r.Method, r.URL.String()) - // If no Accept-Encoding header exists, Transport will add the headers it can accept - // and would wrap the response body with the relevant reader. - r.Header.Del("Accept-Encoding") + if !ctx.Proxy.KeepAcceptEncoding { + // If no Accept-Encoding header exists, Transport will add the headers it can accept + // and would wrap the response body with the relevant reader. + r.Header.Del("Accept-Encoding") + } // curl can add that, see // https://jdebp.eu./FGA/web-proxy-connection-header.html r.Header.Del("Proxy-Connection") @@ -97,16 +134,13 @@ func removeProxyHeaders(ctx *ProxyCtx, r *http.Request) { // options that are desired for that particular connection and MUST NOT // be communicated by proxies over further connections. - // When server reads http request it sets req.Close to true if - // "Connection" header contains "close". - // https://github.com/golang/go/blob/master/src/net/http/request.go#L1080 - // Later, transfer.go adds "Connection: close" back when req.Close is true - // https://github.com/golang/go/blob/master/src/net/http/transfer.go#L275 - // That's why tests that checks "Connection: close" removal fail - if r.Header.Get("Connection") == "close" { - r.Close = false + // We need to keep "Connection: upgrade" header, since it's part of + // the WebSocket handshake, and it won't work without it. + // For all the other cases (close, keep-alive), we already handle them, by + // setting the r.Close variable in the previous lines. + if !isWebSocketHandshake(r.Header) { + r.Header.Del("Connection") } - r.Header.Del("Connection") } type flushWriter struct { @@ -125,102 +159,23 @@ func (fw flushWriter) Write(p []byte) (int, error) { // Standard net/http function. Shouldn't be used directly, http.Serve will use it. func (proxy *ProxyHttpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - //r.Header["X-Forwarded-For"] = w.RemoteAddr() - if r.Method == "CONNECT" { + if r.Method == http.MethodConnect { proxy.handleHttps(w, r) } else { - ctx := &ProxyCtx{Req: r, Session: atomic.AddInt64(&proxy.sess, 1), Proxy: proxy} - - var err error - ctx.Logf("Got request %v %v %v %v", r.URL.Path, r.Host, r.Method, r.URL.String()) - if !r.URL.IsAbs() { - proxy.NonproxyHandler.ServeHTTP(w, r) - return - } - r, resp := proxy.filterRequest(r, ctx) - - if resp == nil { - if isWebSocketRequest(r) { - ctx.Logf("Request looks like websocket upgrade.") - proxy.serveWebsocket(ctx, w, r) - } - - if !proxy.KeepHeader { - removeProxyHeaders(ctx, r) - } - resp, err = ctx.RoundTrip(r) - if err != nil { - ctx.Error = err - resp = proxy.filterResponse(nil, ctx) - - } - if resp != nil { - ctx.Logf("Received response %v", resp.Status) - } - } - - var origBody io.ReadCloser - - if resp != nil { - origBody = resp.Body - defer origBody.Close() - } - - resp = proxy.filterResponse(resp, ctx) - - if resp == nil { - var errorString string - if ctx.Error != nil { - errorString = "error read response " + r.URL.Host + " : " + ctx.Error.Error() - ctx.Logf(errorString) - http.Error(w, ctx.Error.Error(), 500) - } else { - errorString = "error read response " + r.URL.Host - ctx.Logf(errorString) - http.Error(w, errorString, 500) - } - return - } - ctx.Logf("Copying response to client %v [%d]", resp.Status, resp.StatusCode) - // http.ResponseWriter will take care of filling the correct response length - // Setting it now, might impose wrong value, contradicting the actual new - // body the user returned. - // We keep the original body to remove the header only if things changed. - // This will prevent problems with HEAD requests where there's no body, yet, - // the Content-Length header should be set. - if origBody != resp.Body { - resp.Header.Del("Content-Length") - } - copyHeaders(w.Header(), resp.Header, proxy.KeepDestinationHeaders) - w.WriteHeader(resp.StatusCode) - var copyWriter io.Writer = w - if w.Header().Get("content-type") == "text/event-stream" { - // server-side events, flush the buffered data to the client. - copyWriter = &flushWriter{w: w} - } - - nr, err := io.Copy(copyWriter, resp.Body) - if err := resp.Body.Close(); err != nil { - ctx.Warnf("Can't close response body %v", err) - } - ctx.Logf("Copied %v bytes to client error=%v", nr, err) + proxy.handleHttp(w, r) } } -// NewProxyHttpServer creates and returns a proxy server, logging to stderr by default +// NewProxyHttpServer creates and returns a proxy server, logging to stderr by default. func NewProxyHttpServer() *ProxyHttpServer { proxy := ProxyHttpServer{ - Logger: log.New(os.Stderr, "", log.LstdFlags), - reqHandlers: []ReqHandler{}, - respHandlers: []RespHandler{}, - httpsHandlers: []HttpsHandler{}, + Logger: log.New(os.Stderr, "", log.LstdFlags), NonproxyHandler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - http.Error(w, "This is a proxy server. Does not respond to non-proxy requests.", 500) + http.Error(w, "This is a proxy server. Does not respond to non-proxy requests.", http.StatusInternalServerError) }), - Tr: &http.Transport{TLSClientConfig: tlsClientSkipVerify, Proxy: http.ProxyFromEnvironment}, + Tr: &http.Transport{TLSClientConfig: tlsClientSkipVerify, Proxy: http.ProxyFromEnvironment}, + h2Server: &http2.Server{}, } - proxy.ConnectDial = dialerFromEnv(&proxy) - return &proxy } diff --git a/vendor/github.com/elazarl/goproxy/responses.go b/vendor/github.com/elazarl/goproxy/responses.go index e1bf28f..734414c 100644 --- a/vendor/github.com/elazarl/goproxy/responses.go +++ b/vendor/github.com/elazarl/goproxy/responses.go @@ -2,7 +2,7 @@ package goproxy import ( "bytes" - "io/ioutil" + "io" "net/http" ) @@ -22,18 +22,23 @@ func NewResponse(r *http.Request, contentType string, status int, body string) * resp.Header.Add("Content-Type", contentType) resp.StatusCode = status resp.Status = http.StatusText(status) + resp.Proto = "HTTP/1.1" + resp.ProtoMajor = 1 + resp.ProtoMinor = 1 buf := bytes.NewBufferString(body) resp.ContentLength = int64(buf.Len()) - resp.Body = ioutil.NopCloser(buf) + resp.Body = io.NopCloser(buf) return resp } const ( + // ContentTypeText is the MIME type for plain text responses. ContentTypeText = "text/plain" + // ContentTypeHtml is the MIME type for HTML responses. ContentTypeHtml = "text/html" ) -// Alias for NewResponse(r,ContentTypeText,http.StatusAccepted,text) +// Alias for NewResponse(r,ContentTypeText,http.StatusAccepted,text). func TextResponse(r *http.Request, text string) *http.Response { return NewResponse(r, ContentTypeText, http.StatusAccepted, text) } diff --git a/vendor/github.com/elazarl/goproxy/signer.go b/vendor/github.com/elazarl/goproxy/signer.go deleted file mode 100644 index aa511ca..0000000 --- a/vendor/github.com/elazarl/goproxy/signer.go +++ /dev/null @@ -1,108 +0,0 @@ -package goproxy - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rsa" - "crypto/sha1" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "fmt" - "math/big" - "math/rand" - "net" - "runtime" - "sort" - "time" -) - -func hashSorted(lst []string) []byte { - c := make([]string, len(lst)) - copy(c, lst) - sort.Strings(c) - h := sha1.New() - for _, s := range c { - h.Write([]byte(s + ",")) - } - return h.Sum(nil) -} - -func hashSortedBigInt(lst []string) *big.Int { - rv := new(big.Int) - rv.SetBytes(hashSorted(lst)) - return rv -} - -var goproxySignerVersion = ":goroxy1" - -func signHost(ca tls.Certificate, hosts []string) (cert *tls.Certificate, err error) { - var x509ca *x509.Certificate - - // Use the provided ca and not the global GoproxyCa for certificate generation. - if x509ca, err = x509.ParseCertificate(ca.Certificate[0]); err != nil { - return - } - - start := time.Unix(time.Now().Unix()-2592000, 0) // 2592000 = 30 day - end := time.Unix(time.Now().Unix()+31536000, 0) // 31536000 = 365 day - - serial := big.NewInt(rand.Int63()) - template := x509.Certificate{ - // TODO(elazar): instead of this ugly hack, just encode the certificate and hash the binary form. - SerialNumber: serial, - Issuer: x509ca.Subject, - Subject: pkix.Name{ - Organization: []string{"GoProxy untrusted MITM proxy Inc"}, - }, - NotBefore: start, - NotAfter: end, - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - for _, h := range hosts { - if ip := net.ParseIP(h); ip != nil { - template.IPAddresses = append(template.IPAddresses, ip) - } else { - template.DNSNames = append(template.DNSNames, h) - template.Subject.CommonName = h - } - } - - hash := hashSorted(append(hosts, goproxySignerVersion, ":"+runtime.Version())) - var csprng CounterEncryptorRand - if csprng, err = NewCounterEncryptorRandFromKey(ca.PrivateKey, hash); err != nil { - return - } - - var certpriv crypto.Signer - switch ca.PrivateKey.(type) { - case *rsa.PrivateKey: - if certpriv, err = rsa.GenerateKey(&csprng, 2048); err != nil { - return - } - case *ecdsa.PrivateKey: - if certpriv, err = ecdsa.GenerateKey(elliptic.P256(), &csprng); err != nil { - return - } - default: - err = fmt.Errorf("unsupported key type %T", ca.PrivateKey) - } - - var derBytes []byte - if derBytes, err = x509.CreateCertificate(&csprng, &template, x509ca, certpriv.Public(), ca.PrivateKey); err != nil { - return - } - return &tls.Certificate{ - Certificate: [][]byte{derBytes, ca.Certificate[0]}, - PrivateKey: certpriv, - }, nil -} - -func init() { - // Avoid deterministic random numbers - rand.Seed(time.Now().UnixNano()) -} diff --git a/vendor/github.com/elazarl/goproxy/websocket.go b/vendor/github.com/elazarl/goproxy/websocket.go index 522b88e..c10f57c 100644 --- a/vendor/github.com/elazarl/goproxy/websocket.go +++ b/vendor/github.com/elazarl/goproxy/websocket.go @@ -1,11 +1,9 @@ package goproxy import ( - "bufio" - "crypto/tls" "io" + "net" "net/http" - "net/url" "strings" ) @@ -20,42 +18,12 @@ func headerContains(header http.Header, name string, value string) bool { return false } -func isWebSocketRequest(r *http.Request) bool { - return headerContains(r.Header, "Connection", "upgrade") && - headerContains(r.Header, "Upgrade", "websocket") +func isWebSocketHandshake(header http.Header) bool { + return headerContains(header, "Connection", "Upgrade") && + headerContains(header, "Upgrade", "websocket") } -func (proxy *ProxyHttpServer) serveWebsocketTLS(ctx *ProxyCtx, w http.ResponseWriter, req *http.Request, tlsConfig *tls.Config, clientConn *tls.Conn) { - targetURL := url.URL{Scheme: "wss", Host: req.URL.Host, Path: req.URL.Path} - - // Connect to upstream - targetConn, err := tls.Dial("tcp", targetURL.Host, tlsConfig) - if err != nil { - ctx.Warnf("Error dialing target site: %v", err) - return - } - defer targetConn.Close() - - // Perform handshake - if err := proxy.websocketHandshake(ctx, req, targetConn, clientConn); err != nil { - ctx.Warnf("Websocket handshake error: %v", err) - return - } - - // Proxy wss connection - proxy.proxyWebsocket(ctx, targetConn, clientConn) -} - -func (proxy *ProxyHttpServer) serveWebsocket(ctx *ProxyCtx, w http.ResponseWriter, req *http.Request) { - targetURL := url.URL{Scheme: "ws", Host: req.URL.Host, Path: req.URL.Path} - - targetConn, err := proxy.connectDial(ctx, "tcp", targetURL.Host) - if err != nil { - ctx.Warnf("Error dialing target site: %v", err) - return - } - defer targetConn.Close() - +func (proxy *ProxyHttpServer) hijackConnection(ctx *ProxyCtx, w http.ResponseWriter) (net.Conn, error) { // Connect to Client hj, ok := w.(http.Hijacker) if !ok { @@ -64,58 +32,25 @@ func (proxy *ProxyHttpServer) serveWebsocket(ctx *ProxyCtx, w http.ResponseWrite clientConn, _, err := hj.Hijack() if err != nil { ctx.Warnf("Hijack error: %v", err) - return + return nil, err } - - // Perform handshake - if err := proxy.websocketHandshake(ctx, req, targetConn, clientConn); err != nil { - ctx.Warnf("Websocket handshake error: %v", err) - return - } - - // Proxy ws connection - proxy.proxyWebsocket(ctx, targetConn, clientConn) + return clientConn, nil } -func (proxy *ProxyHttpServer) websocketHandshake(ctx *ProxyCtx, req *http.Request, targetSiteConn io.ReadWriter, clientConn io.ReadWriter) error { - // write handshake request to target - err := req.Write(targetSiteConn) - if err != nil { - ctx.Warnf("Error writing upgrade request: %v", err) - return err - } - - targetTLSReader := bufio.NewReader(targetSiteConn) - - // Read handshake response from target - resp, err := http.ReadResponse(targetTLSReader, req) - if err != nil { - ctx.Warnf("Error reading handhsake response %v", err) - return err - } - - // Run response through handlers - resp = proxy.filterResponse(resp, ctx) - - // Proxy handshake back to client - err = resp.Write(clientConn) - if err != nil { - ctx.Warnf("Error writing handshake response: %v", err) - return err - } - return nil -} - -func (proxy *ProxyHttpServer) proxyWebsocket(ctx *ProxyCtx, dest io.ReadWriter, source io.ReadWriter) { - errChan := make(chan error, 2) - cp := func(dst io.Writer, src io.Reader) { - _, err := io.Copy(dst, src) - ctx.Warnf("Websocket error: %v", err) - errChan <- err - } - - // Start proxying websocket data - go cp(dest, source) - go cp(source, dest) - <-errChan +func (proxy *ProxyHttpServer) proxyWebsocket(ctx *ProxyCtx, remoteConn io.ReadWriter, proxyClient io.ReadWriter) { + // 2 is the number of goroutines, this code is implemented according to + // https://stackoverflow.com/questions/52031332/wait-for-one-goroutine-to-finish + waitChan := make(chan struct{}, 2) + go func() { + _ = copyOrWarn(ctx, remoteConn, proxyClient) + waitChan <- struct{}{} + }() + + go func() { + _ = copyOrWarn(ctx, proxyClient, remoteConn) + waitChan <- struct{}{} + }() + + // Wait until one end closes the connection + <-waitChan } diff --git a/vendor/modules.txt b/vendor/modules.txt index 2893b31..79b8389 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -136,9 +136,11 @@ github.com/docker/distribution/manifest/schema2 # github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 ## explicit github.com/docker/libtrust -# github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 -## explicit; go 1.18 +# github.com/elazarl/goproxy v1.9.0 +## explicit; go 1.24.0 github.com/elazarl/goproxy +github.com/elazarl/goproxy/internal/http1parser +github.com/elazarl/goproxy/internal/signer # github.com/evalphobia/logrus_sentry v0.8.2 ## explicit github.com/evalphobia/logrus_sentry From 1f85ead2c38de1803d01086cd7713923f0994eec Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:35:37 +0000 Subject: [PATCH 02/12] Test HTTPS framing on one proxy connection --- proxy_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/proxy_test.go b/proxy_test.go index 5c38d03..e774a87 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -15,6 +15,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "sync/atomic" "testing" "time" @@ -66,6 +67,13 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { client, proxy := testProxyServer(t, testProxyConfig, nil, upstream.Certificate()) defer proxy.Close() + transport := client.Transport.(*http.Transport) + var proxyDials atomic.Int32 + dialer := &net.Dialer{} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + proxyDials.Add(1) + return dialer.DialContext(ctx, network, address) + } tests := []struct { name string @@ -104,6 +112,7 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, "hello", string(body)) + require.Equal(t, int32(1), proxyDials.Load()) } func TestIPRestrictions(t *testing.T) { From 3d0922dc72b82ce6e8c686a3613d9759b8842794 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:50:45 +0000 Subject: [PATCH 03/12] Test cached HTTPS response framing --- proxy.go | 6 +++++- proxy_test.go | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/proxy.go b/proxy.go index 58993b7..078a8a1 100644 --- a/proxy.go +++ b/proxy.go @@ -27,6 +27,10 @@ type Proxy struct { } func newProxy(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIps []net.IP) *Proxy { + return newProxyWithCacheDir(envSettings, cfg, blockedIps, "/cache") +} + +func newProxyWithCacheDir(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIps []net.IP, cacheDir string) *Proxy { var err error if err := setCA([]byte(cfg.CA.Cert), []byte(cfg.CA.Key)); err != nil { @@ -62,7 +66,7 @@ func newProxy(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIp proxy.OnResponse().DoFunc(logger.logResponse) enableCache := os.Getenv("PROXY_CACHE") == "true" - cacher, err := cache.New(enableCache, "/cache") + cacher, err := cache.New(enableCache, cacheDir) if err != nil { log.Fatal(err) } diff --git a/proxy_test.go b/proxy_test.go index e774a87..8057775 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -50,9 +50,13 @@ func TestProxyHTTPRequest(t *testing.T) { } func TestProxyHTTPSMITMResponseFraming(t *testing.T) { + var fixedGETRequests atomic.Int32 upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/fixed": + if r.Method == http.MethodGet { + fixedGETRequests.Add(1) + } _, err := io.WriteString(w, "hello") assert.NoError(t, err) case "/no-content": @@ -65,6 +69,7 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { })) defer upstream.Close() + t.Setenv("PROXY_CACHE", "true") client, proxy := testProxyServer(t, testProxyConfig, nil, upstream.Certificate()) defer proxy.Close() transport := client.Transport.(*http.Transport) @@ -112,6 +117,7 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, "hello", string(body)) + require.Equal(t, int32(1), fixedGETRequests.Load()) require.Equal(t, int32(1), proxyDials.Load()) } @@ -231,7 +237,7 @@ func testProxyServer(t *testing.T, cfg *config.Config, blockedIPs []net.IP, upst srv := &http.Server{ ReadHeaderTimeout: 10 * time.Second, } - proxyHandler := newProxy(envSettings, cfg, blockedIPs) + proxyHandler := newProxyWithCacheDir(envSettings, cfg, blockedIPs, t.TempDir()) if len(upstreamRoots) > 0 { rootCAs, err := x509.SystemCertPool() if err != nil { From 29a7b7b40b2ad0f1f1ff9c2aa068cbef5936449b Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:04:18 +0000 Subject: [PATCH 04/12] Test chunked HTTPS response framing --- proxy_test.go | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/proxy_test.go b/proxy_test.go index 8057775..9426a82 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -59,6 +59,17 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { } _, err := io.WriteString(w, "hello") assert.NoError(t, err) + case "/chunked": + _, err := io.WriteString(w, "hello ") + assert.NoError(t, err) + w.(http.Flusher).Flush() + _, err = io.WriteString(w, "world") + assert.NoError(t, err) + case "/trailers": + w.Header().Set("Trailer", "X-Checksum") + _, err := io.WriteString(w, "trailed") + assert.NoError(t, err) + w.Header().Set("X-Checksum", "abc123") case "/no-content": w.WriteHeader(http.StatusNoContent) case "/not-modified": @@ -86,8 +97,12 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { path string statusCode int body string + chunked bool + trailer string }{ {name: "fixed length", method: http.MethodGet, path: "/fixed", statusCode: http.StatusOK, body: "hello"}, + {name: "unknown length", method: http.MethodGet, path: "/chunked", statusCode: http.StatusOK, body: "hello world", chunked: true}, + {name: "trailers", method: http.MethodGet, path: "/trailers", statusCode: http.StatusOK, body: "trailed", chunked: true, trailer: "abc123"}, {name: "HEAD", method: http.MethodHead, path: "/fixed", statusCode: http.StatusOK}, {name: "no content", method: http.MethodGet, path: "/no-content", statusCode: http.StatusNoContent}, {name: "not modified", method: http.MethodGet, path: "/not-modified", statusCode: http.StatusNotModified}, @@ -106,17 +121,23 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { assert.Equal(t, test.statusCode, resp.StatusCode) assert.Equal(t, test.body, string(body)) + if test.chunked { + assert.Equal(t, []string{"chunked"}, resp.TransferEncoding) + } + if test.trailer != "" { + assert.Equal(t, test.trailer, resp.Trailer.Get("X-Checksum")) + } + + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL+"/fixed", nil) + require.NoError(t, err) + resp, err = client.Do(req) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, "hello", string(body)) }) } - - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL+"/fixed", nil) - require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Equal(t, "hello", string(body)) require.Equal(t, int32(1), fixedGETRequests.Load()) require.Equal(t, int32(1), proxyDials.Load()) } From 059ff4318c089f89b665b758c88c2ac70e3051c1 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:03:35 +0000 Subject: [PATCH 05/12] Trigger CI From d5be5b1b740ef3703019b4b91c0c82284f1124c2 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:20:26 +0000 Subject: [PATCH 06/12] Log bodyless cache responses --- internal/cache/handlers.go | 5 +++++ internal/cache/handlers_test.go | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/internal/cache/handlers.go b/internal/cache/handlers.go index 7d64bfa..1e70b82 100644 --- a/internal/cache/handlers.go +++ b/internal/cache/handlers.go @@ -218,6 +218,11 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R return resp } if responseMustNotHaveBody(resp) { + method := "" + if resp.Request != nil { + method = resp.Request.Method + } + logrus.Warnf("Response has no body (method: %s, status: %d)", method, resp.StatusCode) if resp.Body != nil && resp.Body != http.NoBody { _ = resp.Body.Close() } diff --git a/internal/cache/handlers_test.go b/internal/cache/handlers_test.go index a3e8d86..571e235 100644 --- a/internal/cache/handlers_test.go +++ b/internal/cache/handlers_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/elazarl/goproxy" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -127,6 +128,11 @@ func TestCache_BodyForbiddenResponses(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + var logOutput bytes.Buffer + originalOutput := logrus.StandardLogger().Out + logrus.SetOutput(&logOutput) + defer logrus.SetOutput(originalOutput) + cacher, err := New(true, t.TempDir()) require.NoError(t, err) @@ -152,6 +158,7 @@ func TestCache_BodyForbiddenResponses(t *testing.T) { assert.Empty(t, result.Header.Values("Transfer-Encoding")) assert.Empty(t, cacher.cacheDB) assert.Zero(t, cacher.callCursor) + assert.Contains(t, logOutput.String(), "Response has no body (method: "+test.method+", status: "+strconv.Itoa(test.statusCode)+")") }) } } From 91d24f77377f102c3621cd6bd4b005990b1231b6 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:03:17 +0000 Subject: [PATCH 07/12] Add cache response regression coverage --- internal/cache/handlers.go | 8 +++ internal/cache/handlers_test.go | 52 ++++++++++++++ proxy_test.go | 123 ++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) diff --git a/internal/cache/handlers.go b/internal/cache/handlers.go index 1e70b82..27ebf9a 100644 --- a/internal/cache/handlers.go +++ b/internal/cache/handlers.go @@ -231,6 +231,14 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R resp.Header.Del("Transfer-Encoding") return resp } + if resp.Body == nil { + method := "" + if resp.Request != nil { + method = resp.Request.Method + } + logrus.Errorf("Response unexpectedly has nil body (method: %s, status: %d)", method, resp.StatusCode) + return resp + } k, ok := proxyctx.GetValue(proxyCtx, keyValue) if !ok { // can't calculate key as response body is empty diff --git a/internal/cache/handlers_test.go b/internal/cache/handlers_test.go index 571e235..01844d9 100644 --- a/internal/cache/handlers_test.go +++ b/internal/cache/handlers_test.go @@ -188,6 +188,58 @@ func TestCache_SwitchingProtocolsPreservesUpgradedStream(t *testing.T) { assert.Zero(t, cacher.callCursor) } +func TestCache_UnexpectedNilBodyIsNotCached(t *testing.T) { + var logOutput bytes.Buffer + originalOutput := logrus.StandardLogger().Out + logrus.SetOutput(&logOutput) + defer logrus.SetOutput(originalOutput) + + cacheDir := t.TempDir() + cacher, err := New(true, cacheDir) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, URL, nil) + proxyCtx := &goproxy.ProxyCtx{Req: req} + proxyctx.SetValue(proxyCtx, keyValue, Key{Method: req.Method, URL: req.URL.String()}) + resp := &http.Response{ + Request: req, + StatusCode: http.StatusOK, + } + + result := cacher.OnResponse(resp, proxyCtx) + + assert.Same(t, resp, result) + assert.Nil(t, result.Body) + assert.Empty(t, cacher.cacheDB) + assert.Zero(t, cacher.callCursor) + entries, err := os.ReadDir(cacheDir) + require.NoError(t, err) + assert.Empty(t, entries) + assert.Contains(t, logOutput.String(), "Response unexpectedly has nil body (method: GET, status: 200)") +} + +func TestCache_ZeroByteBodyIsCached(t *testing.T) { + cacher, err := New(true, t.TempDir()) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, URL, nil) + proxyCtx := &goproxy.ProxyCtx{Req: req} + proxyctx.SetValue(proxyCtx, keyValue, Key{Method: req.Method, URL: req.URL.String()}) + resp := &http.Response{ + Request: req, + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + } + + result := cacher.OnResponse(resp, proxyCtx) + _, err = io.ReadAll(result.Body) + require.NoError(t, err) + require.NoError(t, result.Body.Close()) + + assert.Len(t, cacher.cacheDB, 1) + assert.Equal(t, 1, cacher.callCursor) +} + func Test_sanitize(t *testing.T) { var tests = []struct { Input, Expected string diff --git a/proxy_test.go b/proxy_test.go index 9426a82..8afc98b 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -10,6 +10,7 @@ import ( "crypto/x509/pkix" "encoding/pem" "io" + "log" "math/big" "net" "net/http" @@ -19,6 +20,7 @@ import ( "testing" "time" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -142,6 +144,127 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { require.Equal(t, int32(1), proxyDials.Load()) } +func TestProxyHTTPSConditionalNotModifiedPreservesCachedResponse(t *testing.T) { + const ( + etag = `"v1"` + body = "cached response" + ) + var upstreamRequests atomic.Int32 + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests.Add(1) + w.Header().Set("ETag", etag) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + _, err := io.WriteString(w, body) + assert.NoError(t, err) + })) + defer upstream.Close() + + t.Setenv("PROXY_CACHE", "true") + client, proxy := testProxyServer(t, testProxyConfig, nil, upstream.Certificate()) + defer proxy.Close() + transport := client.Transport.(*http.Transport) + var proxyDials atomic.Int32 + dialer := &net.Dialer{} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + proxyDials.Add(1) + return dialer.DialContext(ctx, network, address) + } + + request := func(ifNoneMatch string) *http.Response { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL, nil) + require.NoError(t, err) + if ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } + resp, err := client.Do(req) + require.NoError(t, err) + return resp + } + + resp := request("") + responseBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, body, string(responseBody)) + + resp = request(etag) + responseBody, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusNotModified, resp.StatusCode) + assert.Empty(t, responseBody) + + resp = request("") + responseBody, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, body, string(responseBody)) + assert.Equal(t, etag, resp.Header.Get("ETag")) + + assert.Equal(t, int32(2), upstreamRequests.Load()) + assert.Equal(t, int32(1), proxyDials.Load()) +} + +func TestProxyUpstreamCloseIsNotCachedAsBodylessResponse(t *testing.T) { + var logOutput bytes.Buffer + originalLogOutput := log.Writer() + originalLogrusOutput := logrus.StandardLogger().Out + log.SetOutput(&logOutput) + logrus.SetOutput(&logOutput) + defer log.SetOutput(originalLogOutput) + defer logrus.SetOutput(originalLogrusOutput) + + var upstreamRequests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if upstreamRequests.Add(1) == 1 { + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + require.NoError(t, conn.Close()) + return + } + _, err := io.WriteString(w, "recovered") + assert.NoError(t, err) + })) + defer upstream.Close() + + t.Setenv("PROXY_CACHE", "true") + client, proxy := testProxyServer(t, testProxyConfig, nil) + defer proxy.Close() + + request := func() *http.Response { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + return resp + } + + resp := request() + _, err := io.Copy(io.Discard, resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + for range 2 { + resp = request() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "recovered", string(body)) + } + + assert.Equal(t, int32(2), upstreamRequests.Load()) + assert.Contains(t, logOutput.String(), "No response from server") + assert.Contains(t, logOutput.String(), "Received nil response") + assert.NotContains(t, logOutput.String(), "Response has no body") +} + func TestIPRestrictions(t *testing.T) { blockedIPs = []net.IP{iPV4Localhost, iPV6Localhost} client, proxy := testProxyServer(t, testProxyConfig, blockedIPs) From ca2ca24aabcdcba3374bf9465973e7ceb475f465 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:06:56 +0000 Subject: [PATCH 08/12] Remove goproxy migration audit --- docs/goproxy-v1.9.0-migration.md | 490 ------------------------------- 1 file changed, 490 deletions(-) delete mode 100644 docs/goproxy-v1.9.0-migration.md diff --git a/docs/goproxy-v1.9.0-migration.md b/docs/goproxy-v1.9.0-migration.md deleted file mode 100644 index 07fafe7..0000000 --- a/docs/goproxy-v1.9.0-migration.md +++ /dev/null @@ -1,490 +0,0 @@ -# goproxy v1.9.0 compatibility audit - -## Scope - -This audit compares the pre-migration goproxy version, -`v0.0.0-20240726154733-8b0c20506380`, with `v1.9.0` -(`6225cd309d7c`). It does not use previous upgrade attempts as evidence. - -The audit covers every production interaction between Dependabot Proxy and -goproxy: - -1. Proxy server creation -2. Outbound connection configuration -3. HTTPS interception -4. MITM CA injection -5. Request handler registration -6. Credential injection -7. `ProxyCtx` state sharing -8. Response handler registration -9. Retry behavior - -## Verdict - -The pre-migration Dependabot Proxy source was API-compatible with goproxy -`v1.9.0`. An isolated copy of that source was changed to require `v1.9.0`, then -`go mod tidy` and `go test ./...` completed successfully without production -code changes. - -That result proves source compatibility and current unit-test compatibility. -It does not fully prove wire compatibility. The largest behavioral change is -the HTTP/1 MITM response writer, and the current test suite does not exercise a -successful HTTPS response through the complete proxy. - -One production adaptation was required at the response-cache boundary. The -cache must not replace `http.NoBody` on responses for which HTTP semantics -forbid a body. The migration implementation now: - -1. Updates `go.mod`, `go.sum`, and `vendor` to goproxy `v1.9.0`. -2. Prevents the cache from wrapping HEAD, non-101 informational, 204, and 304 - responses; preserves the upgraded stream on 101 responses. -3. Adds end-to-end HTTPS MITM framing coverage for GET, HEAD, 204, 304, and a - subsequent response on the same constrained connection pool. -4. Passes the complete test suite twice with race detection and randomized - test order. -5. Keeps `AllowHTTP2` at its default value, `false`, for this upgrade. - -## Go and tooling compatibility - -goproxy modernized its own Go baseline and development tooling between the -pinned commit and `v1.9.0`, but Dependabot Proxy does not need a corresponding -toolchain upgrade: - -| Area | Pinned goproxy | goproxy `v1.9.0` | Dependabot Proxy | Action | -| --- | --- | --- | --- | --- | -| Go directive | `1.18` | `1.24.0` | `1.26.0` | None; Dependabot exceeds the dependency minimum | -| Container compiler | Not imposed on consumers | Requires Go 1.24 or newer | Go `1.26.5` builder | None | -| CI Go selection | Upstream-specific | Upstream-specific | Read from Dependabot's `go.mod` | None | -| Race tests | Upstream test policy | Supported | Docker test runs `-race -count=2` | Retain and run after upgrade | -| Lint configuration | No root config in the pinned commit | Broad golangci-lint v2 config | Smaller repository-specific golangci-lint v2 config, not invoked by CI | Optionally enforce Dependabot's existing config in separate maintenance work | - -The `v1.9.0` module introduces `github.com/coder/websocket` and newer minimum -versions of `golang.org/x/net` and `golang.org/x/text`. Go's minimal version -selection uses Dependabot's already newer `golang.org/x/net` and -`golang.org/x/text` versions. `go mod tidy` and the vendor update must record -the selected dependency graph, including the new websocket package. - -goproxy also modernized internal code by replacing APIs such as `ioutil` with -`io` and `os`, using `any` instead of `interface{}`, using context-aware -`DialContext` and TLS handshake methods, typed atomics, `errors.Is`, -`slices.Contains`, named HTTP status constants, and -`http.NewResponseController`. These changes are internal to goproxy and do not -require Dependabot adapters. Dependabot production code already avoids -`ioutil` and empty-interface declarations, supplies `DialContext`, uses named -HTTP status constants, and builds with a Go version that provides all of these -APIs. - -The vendored goproxy `.golangci.yml` describes upstream's contributor policy; -it is not Dependabot's lint policy. Vendored code remains excluded from -Dependabot's formatting checks. Dependabot CI currently enforces `gofmt`, -`go vet`, `go mod tidy -diff`, builds, and tests, but does not invoke -`golangci-lint` despite the root configuration. Enforcing Dependabot's existing -configuration, or separately evaluating goproxy's additional linters and -`gofumpt`/`gci` formatters, may be useful maintenance work. Neither is a -compatibility requirement for `v1.9.0`, and importing the upstream policy into -this upgrade would create unrelated code churn. - -## Request flow - -```mermaid -sequenceDiagram - participant U as Updater - participant G as goproxy - participant H as Dependabot handlers - participant T as Dependabot transport - participant R as Registry or Git server - - U->>G: HTTP request or HTTPS CONNECT - G->>G: Establish MITM TLS for HTTPS - G->>H: Run request handlers in order - H-->>G: Request or immediate response - G->>T: ProxyCtx.RoundTrip(request) - T->>R: Dial and send request - R-->>G: Response - G->>H: Run response handlers in order - H-->>G: Original or replacement response - G-->>U: Serialize final response -``` - -## Primary behavior change: MITM response framing - -The pinned version writes intercepted HTTPS responses manually. It: - -- writes an `HTTP/1.1` status line directly; -- removes `Content-Length` for every non-HEAD response; -- sets `Transfer-Encoding: chunked` for every non-HEAD response; -- writes chunks using goproxy's custom chunk writer; and -- sets `Connection: close`. - -In `v1.9.0`, goproxy prepares the response and calls: - -```go -resp.Write(&responseHeadWriter{writer: client}) -``` - -Before that call, goproxy: - -- marks a response as chunked when a handler replaced its body or its length is - unknown; -- normalizes the downstream protocol fields to `HTTP/1.1`; and -- removes a stale `Content-Length` when chunking is required. - -Go's `http.Response.Write` now owns the status line, HEAD semantics, -`Content-Length`, chunking, connection-close behavior, body framing, and -trailers. goproxy's `responseHeadWriter` buffers only until the complete header -has been written as one unit, then streams subsequent body writes directly to -the client. - -This is not an API break, but it changes the contract for every -`*http.Response` returned or modified by Dependabot handlers. The final -response must have: - -- a nonzero valid `StatusCode`; -- a non-nil `Header` when a handler intends to mutate headers; -- a `Request` when method-dependent behavior such as HEAD is required; -- a readable, closable `Body`, or `http.NoBody` for a known empty body; and -- consistent `Body`, `ContentLength`, `TransferEncoding`, and trailer fields. - -### Cache incompatibility - -The current cache violates this stricter response contract. Its response -handler wraps every cacheable body with `TeeReadCloser`, including -`http.NoBody`. The resulting `v1.9.0` flow is: - -1. The upstream transport returns a response whose body is `http.NoBody`, such - as a 304 response. -2. The cache replaces that sentinel with `TeeReadCloser`. -3. goproxy observes that a response handler changed the body. -4. goproxy sets unknown-length chunked framing. -5. `http.Response.Write` serializes the response using those fields. -6. Framing bytes for a response that must not have a body remain on the - persistent connection and can be parsed as the next response's status line. - -The cache must bypass body wrapping and storage for: - -- all HEAD responses; -- all informational responses from 100 through 199; -- 204 No Content; and -- 304 Not Modified. - -There is one important exception in cleanup behavior: a 101 Switching -Protocols response carries an upgraded `io.ReadWriter` stream. The cache must -return it unchanged and must not close it. Other body-forbidden responses -should close a non-sentinel original body, set `Body` to `http.NoBody`, clear -`TransferEncoding`, and remove the `Transfer-Encoding` header. - -## Input compatibility audit - -### 1. Proxy server creation - -Current input: - -```go -proxy := goproxy.NewProxyHttpServer() -``` - -`NewProxyHttpServer` retains the same signature and the returned -`*ProxyHttpServer` still implements `http.Handler`. In `v1.9.0` the constructor -also initializes private HTTP/2 server state, so continuing to use the -constructor is correct. - -**Compatibility:** compatible. - -**Required change:** none. - -### 2. Outbound connection configuration - -Dependabot replaces `proxy.Tr` with this `*http.Transport` input: - -```go -&http.Transport{ - Dial: safeDialer.Dial, - DialContext: safeDialer.DialContext, - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - }, - Proxy: http.ProxyFromEnvironment, -} -``` - -`ProxyHttpServer.Tr` remains `*http.Transport`. `ProxyCtx.RoundTrip` still uses -`proxy.Tr.RoundTrip`, and goproxy's dial path still prefers `Tr.DialContext` -when no request-specific or CONNECT dialer overrides it. Dependabot supplies a -non-nil `DialContext`, so the safe dialer remains valid. - -The nil `RootCAs` value continues to select the system trust store. Unlike -goproxy's default transport, Dependabot's transport does not set -`InsecureSkipVerify`, so upstream certificates continue to be verified. - -The constructor may initialize `ConnectDial` from `HTTPS_PROXY`. This behavior -exists independently of the `Tr` replacement and is not new in `v1.9.0`. - -**Compatibility:** compatible. - -**Required change:** none. - -**Required test:** verify a resolved blocked IP still produces the expected -result through both HTTP and HTTPS paths after the dependency update. - -### 3. HTTPS interception - -Current input: - -```go -proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm) -``` - -`AlwaysMitm`, `HandleConnect`, `ConnectAction`, and `ConnectMitm` retain their -signatures. For HTTP/1 clients, `v1.9.0` still terminates client TLS, parses the -intercepted request, runs request handlers, sends the upstream request, runs -response handlers, and writes the final response. - -Important internal changes include: - -- request contexts are canceled after each intercepted request; -- origin-form and absolute-form request targets are normalized separately; -- upstream HTTP/2 responses are normalized to downstream HTTP/1.1; -- MITM response heads are coalesced; and -- response bodies are streamed through `http.Response.Write`. - -`AllowHTTP2` is still disabled by default, and the current source does not set -it. The new HTTP/2 MITM implementation is therefore outside the active -Dependabot request path for this upgrade. - -**Compatibility:** API-compatible; wire behavior requires validation. - -**Required change:** no production change. - -**Required tests:** successful HTTPS GET and HEAD, known and unknown body -lengths, empty body, 204, 304, large streamed body, trailers, and cancellation. - -### 4. MITM CA injection - -Dependabot parses the configured certificate and key with `tls.X509KeyPair`, -parses `ca.Leaf`, assigns `goproxy.GoproxyCa`, rebuilds the predefined CONNECT -actions with `TLSConfigFromCA`, and supplies `proxy.CertStore`. - -In `v1.9.0`: - -- `GoproxyCa` remains a `tls.Certificate`; -- `TLSConfigFromCA` retains its signature; -- `ConnectAction.TLSConfig` retains its signature; -- `CertStorage.Fetch` retains its signature; and -- the signer accepts RSA, ECDSA, and Ed25519 CA private keys. - -The current `certStore.Fetch` serializes access with a mutex and returns the -generated certificate for the normalized hostname. This satisfies the -`v1.9.0` interface and avoids duplicate concurrent generation. - -`HTTPMitmConnect` is deprecated in `v1.9.0` but still present. Assigning it is -source-compatible, and the active `AlwaysMitm` path uses `MitmConnect`. - -**Compatibility:** compatible. - -**Required change:** none. Removing the unused deprecated -`HTTPMitmConnect` assignment can be handled separately and is not required for -this upgrade. - -**Required test:** trust the configured CA, complete an HTTPS request, and -verify repeated requests reuse a valid host certificate. - -### 5. Request handler registration - -Dependabot registers handlers with `OnRequest().DoFunc`. The callback remains: - -```go -func(*http.Request, *goproxy.ProxyCtx) (*http.Request, *http.Response) -``` - -`v1.9.0` still executes request handlers in registration order and stops at -the first non-nil response. Current handlers return either the mutable request -and `nil`, or the request and a complete immediate response. These are valid -inputs. - -**Compatibility:** compatible. - -**Required change:** none. - -**Required test:** assert order and short-circuit behavior through the running -proxy, not only by invoking handlers directly. - -### 6. Credential injection - -Credential handlers mutate the supplied request using standard headers such as -`Authorization`, `X-GitHub-PSI-JWT`, and registry-specific headers. They return -the same request to goproxy. - -`v1.9.0` removes hop-by-hop proxy headers before forwarding, but it does not -remove ordinary end-server `Authorization` headers. The mutable request and -header contract is unchanged, so current credential inputs remain valid. - -**Compatibility:** compatible. - -**Required change:** none. - -**Required tests:** use two TLS upstream servers to prove that matching -credentials arrive at the intended server and never arrive at an unmatched -server. - -### 7. `ProxyCtx` state sharing - -Dependabot stores `map[string]any` in `ProxyCtx.UserData` through -`internal/proxyctx`. `UserData` changed from `interface{}` to its alias `any`, -which is source-compatible. For the active HTTP/1 path, the same `ProxyCtx` -still reaches the request and response handlers. - -`RoundTripper`, `Req`, `Resp`, `Error`, `Session`, and `Proxy` remain -available. `v1.9.0` adds a request-specific `Dialer`; Dependabot does not set -it. - -When HTTP/2 MITM is enabled, goproxy copies the CONNECT parent's `UserData` -into per-stream contexts. Dependabot's map is not concurrency-safe. This does -not affect the current upgrade because `AllowHTTP2` remains false, but it must -be addressed before enabling HTTP/2. - -**Compatibility:** compatible with the active HTTP/1 configuration. - -**Required change:** none for this upgrade. - -**Required tests:** request-to-response visibility and isolation between -separate HTTP/1 requests. Run with `-race`. - -### 8. Response handler registration and response inputs - -Dependabot registers handlers with `OnResponse().DoFunc`. The callback remains: - -```go -func(*http.Response, *goproxy.ProxyCtx) *http.Response -``` - -`v1.9.0` still executes all response handlers in registration order and sets -`ProxyCtx.Resp` before each call. - -Dependabot supplies three response shapes: - -| Source | Current fields | `v1.9.0` result | -| --- | --- | --- | -| Upstream `http.Transport` | Complete standard-library response | Compatible | -| `goproxy.NewResponse` in security handlers | Request, status, header, length, body | Compatible; `v1.9.0` also initializes HTTP/1.1 protocol fields | -| Disk-cache hit | Request, status code, saved headers, file body | Accepted; goproxy normalizes protocol and uses chunked or close-delimited framing when length metadata is incomplete | - -The logger and cache may replace or wrap `resp.Body`. `v1.9.0` detects a body -identity change, deletes stale `Content-Length`, and selects chunked framing. -That is compatible with the current wrappers and is a behavior that must be -tested on the wire. - -The cache does not restore `Status`, protocol fields, or `ContentLength`. -`v1.9.0` derives the status text, normalizes the protocol, and safely handles -the unknown length. Restoring `ContentLength` would improve keep-alive framing -but is not required for correctness or for this dependency upgrade. - -Response handlers must continue returning a non-nil response with a non-nil -body on the successful MITM path. Current production handlers satisfy that -contract. - -**Compatibility:** upstream responses, generated responses, and body wrappers -are compatible except for the cache wrapping body-forbidden responses. - -**Required change:** update `internal/cache.DB.OnResponse` to bypass caching for -HEAD, 1xx, 204, and 304 responses. Preserve the original body for 101; normalize -the other body-forbidden responses to `http.NoBody` and clear transfer encoding. - -**Required tests:** generated 403 response, cache hit, logged 401/403 body, -handler-wrapped body, trailers, body-forbidden statuses, 101 upgraded-stream -preservation, and response-handler order through HTTPS MITM. - -### 9. Retry behavior - -GitHub and Git handlers clone `ProxyCtx.Req`, change authentication, and call: - -```go -proxyCtx.RoundTrip(newReq) -``` - -Docker authentication installs a custom implementation of goproxy's -`RoundTripper` interface. Both contracts are unchanged: - -```go -type RoundTripper interface { - RoundTrip(*http.Request, *ProxyCtx) (*http.Response, error) -} -``` - -`ProxyCtx.RoundTrip` still delegates to the custom round tripper when present, -otherwise to `ProxyHttpServer.Tr`. Retry calls occur inside the active request -context before `v1.9.0` cancels that context. Current retry requests and -replacement responses are therefore valid. - -Dependabot, not goproxy, continues to own retry eligibility, alternate -credential selection, and draining discarded response bodies. - -**Compatibility:** compatible. - -**Required change:** none. - -**Required tests:** first credential fails and second succeeds, all credentials -fail, retry round trip returns an error, POST body replay, Docker custom round -tripper, and discarded-body closure through the running HTTPS proxy. - -## Required implementation work - -### Production files - -Update the response cache in addition to dependency metadata and vendored -goproxy code: - -- `go.mod` -- `go.sum` -- `internal/cache/handlers.go` -- `internal/cache/handlers_test.go` -- `vendor/modules.txt` -- `vendor/github.com/elazarl/goproxy/**` - -Do not enable `ProxyHttpServer.AllowHTTP2` as part of this upgrade. - -### Tests - -Add integration coverage that starts: - -1. a TLS upstream server; -2. the complete Dependabot proxy with its configured CA; and -3. an HTTP client that trusts that CA and connects through the proxy. - -The test matrix must cover: - -| Area | Cases | -| --- | --- | -| Framing | GET, HEAD, empty, fixed length, unknown length, 204, 304, trailers, large stream | -| Immediate responses | metadata-host 403 and blocked-IP behavior | -| Handler bodies | logger replay and cache tee wrapper | -| Cache | first upstream response, subsequent cache hit, HEAD, 1xx, 204, 304, and 101 upgraded-stream preservation | -| Credentials | matching injection and unmatched isolation | -| Context | request/response state visibility and request isolation | -| Retries | alternate auth success/failure and replayed request body | -| Certificates | configured CA trust and certificate-store reuse | - -After adding focused tests, run: - -```bash -go test ./... -go test -race -count=2 ./... -``` - -## Migration checklist - -- [x] Compare all production goproxy APIs used by the current source. -- [x] Inspect the old and new internal HTTP/MITM control flow. -- [x] Validate current inputs passed to goproxy. -- [x] Compile and run current tests unchanged against `v1.9.0` in isolation. -- [x] Identify the cache/body-framing incompatibility. -- [x] Prevent caching or wrapping body-forbidden responses while preserving 101 upgraded streams. -- [x] Add initial end-to-end HTTPS MITM framing tests for GET, HEAD, 204, 304, and connection reuse. -- [ ] Extend HTTPS MITM framing tests to empty and unknown-length bodies, trailers, large streams, and cancellation. -- [ ] Add cache, credential, context, and retry integration tests. -- [x] Update the dependency and vendor directory. -- [x] Run the full suite with race detection. - -## Upstream references - -- [`v1.9.0` source](https://github.com/elazarl/goproxy/tree/v1.9.0) -- [`8b0c20506380...v1.9.0` comparison](https://github.com/elazarl/goproxy/compare/8b0c20506380...v1.9.0) \ No newline at end of file From ae98988b12c791496c776fd99084bedac595664e Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:30:47 +0000 Subject: [PATCH 09/12] Normalize empty cache responses --- internal/cache/handlers.go | 6 ++++++ internal/cache/handlers_test.go | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/cache/handlers.go b/internal/cache/handlers.go index 27ebf9a..a0cde9a 100644 --- a/internal/cache/handlers.go +++ b/internal/cache/handlers.go @@ -237,6 +237,11 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R method = resp.Request.Method } logrus.Errorf("Response unexpectedly has nil body (method: %s, status: %d)", method, resp.StatusCode) + resp.Body = http.NoBody + resp.ContentLength = 0 + resp.TransferEncoding = nil + resp.Header.Del("Content-Length") + resp.Header.Del("Transfer-Encoding") return resp } k, ok := proxyctx.GetValue(proxyCtx, keyValue) @@ -287,6 +292,7 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R func responseMustNotHaveBody(resp *http.Response) bool { return resp.StatusCode >= 100 && resp.StatusCode < 200 || resp.StatusCode == http.StatusNoContent || + resp.StatusCode == http.StatusResetContent || resp.StatusCode == http.StatusNotModified || resp.Request != nil && resp.Request.Method == http.MethodHead } diff --git a/internal/cache/handlers_test.go b/internal/cache/handlers_test.go index 01844d9..efef39a 100644 --- a/internal/cache/handlers_test.go +++ b/internal/cache/handlers_test.go @@ -123,6 +123,7 @@ func TestCache_BodyForbiddenResponses(t *testing.T) { {name: "informational", method: http.MethodGet, statusCode: http.StatusEarlyHints}, {name: "HEAD", method: http.MethodHead, statusCode: http.StatusOK}, {name: "no content", method: http.MethodGet, statusCode: http.StatusNoContent}, + {name: "reset content", method: http.MethodGet, statusCode: http.StatusResetContent}, {name: "not modified", method: http.MethodGet, statusCode: http.StatusNotModified}, } @@ -202,14 +203,21 @@ func TestCache_UnexpectedNilBodyIsNotCached(t *testing.T) { proxyCtx := &goproxy.ProxyCtx{Req: req} proxyctx.SetValue(proxyCtx, keyValue, Key{Method: req.Method, URL: req.URL.String()}) resp := &http.Response{ - Request: req, - StatusCode: http.StatusOK, + Request: req, + StatusCode: http.StatusOK, + Header: http.Header{"Content-Length": []string{"10"}, "Transfer-Encoding": []string{"chunked"}}, + ContentLength: 10, + TransferEncoding: []string{"chunked"}, } result := cacher.OnResponse(resp, proxyCtx) assert.Same(t, resp, result) - assert.Nil(t, result.Body) + assert.Equal(t, http.NoBody, result.Body) + assert.Zero(t, result.ContentLength) + assert.Empty(t, result.TransferEncoding) + assert.Empty(t, result.Header.Values("Content-Length")) + assert.Empty(t, result.Header.Values("Transfer-Encoding")) assert.Empty(t, cacher.cacheDB) assert.Zero(t, cacher.callCursor) entries, err := os.ReadDir(cacheDir) From b2f8588c3a3353a76e82d6170377f34750c815b2 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:49:10 +0000 Subject: [PATCH 10/12] Clear reset response content length --- internal/cache/handlers.go | 4 ++++ internal/cache/handlers_test.go | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/cache/handlers.go b/internal/cache/handlers.go index a0cde9a..6bf1733 100644 --- a/internal/cache/handlers.go +++ b/internal/cache/handlers.go @@ -229,6 +229,10 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R resp.Body = http.NoBody resp.TransferEncoding = nil resp.Header.Del("Transfer-Encoding") + if resp.StatusCode == http.StatusResetContent { + resp.ContentLength = 0 + resp.Header.Del("Content-Length") + } return resp } if resp.Body == nil { diff --git a/internal/cache/handlers_test.go b/internal/cache/handlers_test.go index efef39a..f84014a 100644 --- a/internal/cache/handlers_test.go +++ b/internal/cache/handlers_test.go @@ -116,14 +116,15 @@ func TestCache(t *testing.T) { func TestCache_BodyForbiddenResponses(t *testing.T) { tests := []struct { - name string - method string - statusCode int + name string + method string + statusCode int + clearContentLength bool }{ {name: "informational", method: http.MethodGet, statusCode: http.StatusEarlyHints}, {name: "HEAD", method: http.MethodHead, statusCode: http.StatusOK}, {name: "no content", method: http.MethodGet, statusCode: http.StatusNoContent}, - {name: "reset content", method: http.MethodGet, statusCode: http.StatusResetContent}, + {name: "reset content", method: http.MethodGet, statusCode: http.StatusResetContent, clearContentLength: true}, {name: "not modified", method: http.MethodGet, statusCode: http.StatusNotModified}, } @@ -145,8 +146,9 @@ func TestCache_BodyForbiddenResponses(t *testing.T) { resp := &http.Response{ Request: req, StatusCode: test.statusCode, - Header: http.Header{"Transfer-Encoding": []string{"chunked"}}, + Header: http.Header{"Content-Length": []string{"10"}, "Transfer-Encoding": []string{"chunked"}}, Body: originalBody, + ContentLength: 10, TransferEncoding: []string{"chunked"}, } @@ -157,6 +159,13 @@ func TestCache_BodyForbiddenResponses(t *testing.T) { assert.True(t, originalBody.WasCloseCalled) assert.Empty(t, result.TransferEncoding) assert.Empty(t, result.Header.Values("Transfer-Encoding")) + if test.clearContentLength { + assert.Zero(t, result.ContentLength) + assert.Empty(t, result.Header.Values("Content-Length")) + } else { + assert.Equal(t, int64(10), result.ContentLength) + assert.Equal(t, "10", result.Header.Get("Content-Length")) + } assert.Empty(t, cacher.cacheDB) assert.Zero(t, cacher.callCursor) assert.Contains(t, logOutput.String(), "Response has no body (method: "+test.method+", status: "+strconv.Itoa(test.statusCode)+")") From 6c2ec674114d5d7206c4163d56dd2e44156834a9 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:00:11 +0000 Subject: [PATCH 11/12] Refine response framing checks --- internal/cache/handlers.go | 14 ++++++++++---- internal/cache/handlers_test.go | 24 ++++++++++++++++++++++++ proxy_test.go | 27 ++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/internal/cache/handlers.go b/internal/cache/handlers.go index 6bf1733..fc3d9c5 100644 --- a/internal/cache/handlers.go +++ b/internal/cache/handlers.go @@ -218,11 +218,17 @@ func (d *DB) OnResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.R return resp } if responseMustNotHaveBody(resp) { - method := "" - if resp.Request != nil { - method = resp.Request.Method + invalidContentLength := resp.StatusCode == http.StatusResetContent && + (resp.ContentLength != 0 || resp.Header.Get("Content-Length") != "") + if resp.Body != nil && resp.Body != http.NoBody || + len(resp.TransferEncoding) > 0 || resp.Header.Get("Transfer-Encoding") != "" || + invalidContentLength { + method := "" + if resp.Request != nil { + method = resp.Request.Method + } + logrus.Warnf("Response has no body (method: %s, status: %d)", method, resp.StatusCode) } - logrus.Warnf("Response has no body (method: %s, status: %d)", method, resp.StatusCode) if resp.Body != nil && resp.Body != http.NoBody { _ = resp.Body.Close() } diff --git a/internal/cache/handlers_test.go b/internal/cache/handlers_test.go index f84014a..d7bdee7 100644 --- a/internal/cache/handlers_test.go +++ b/internal/cache/handlers_test.go @@ -173,6 +173,30 @@ func TestCache_BodyForbiddenResponses(t *testing.T) { } } +func TestCache_RoutineBodyForbiddenResponseDoesNotWarn(t *testing.T) { + var logOutput bytes.Buffer + originalOutput := logrus.StandardLogger().Out + logrus.SetOutput(&logOutput) + defer logrus.SetOutput(originalOutput) + + cacher, err := New(true, t.TempDir()) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, URL, nil) + resp := &http.Response{ + Request: req, + StatusCode: http.StatusNotModified, + Header: make(http.Header), + Body: http.NoBody, + } + + result := cacher.OnResponse(resp, &goproxy.ProxyCtx{Req: req}) + + assert.Same(t, resp, result) + assert.Equal(t, http.NoBody, result.Body) + assert.NotContains(t, logOutput.String(), "Response has no body") +} + func TestCache_SwitchingProtocolsPreservesUpgradedStream(t *testing.T) { cacher, err := New(true, t.TempDir()) require.NoError(t, err) diff --git a/proxy_test.go b/proxy_test.go index 8afc98b..e1d52d2 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -51,6 +51,31 @@ func TestProxyHTTPRequest(t *testing.T) { assert.Equal(t, 200, rsp.StatusCode) } +func TestProxyHTTPSMITMFixedLengthResponseFraming(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "5") + _, err := io.WriteString(w, "hello") + assert.NoError(t, err) + })) + defer upstream.Close() + + t.Setenv("PROXY_CACHE", "false") + client, proxy := testProxyServer(t, testProxyConfig, nil, upstream.Certificate()) + defer proxy.Close() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + assert.Equal(t, "hello", string(body)) + assert.Empty(t, resp.TransferEncoding) + assert.Equal(t, int64(len(body)), resp.ContentLength) +} + func TestProxyHTTPSMITMResponseFraming(t *testing.T) { var fixedGETRequests atomic.Int32 upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -102,7 +127,7 @@ func TestProxyHTTPSMITMResponseFraming(t *testing.T) { chunked bool trailer string }{ - {name: "fixed length", method: http.MethodGet, path: "/fixed", statusCode: http.StatusOK, body: "hello"}, + {name: "cache-wrapped fixed body", method: http.MethodGet, path: "/fixed", statusCode: http.StatusOK, body: "hello", chunked: true}, {name: "unknown length", method: http.MethodGet, path: "/chunked", statusCode: http.StatusOK, body: "hello world", chunked: true}, {name: "trailers", method: http.MethodGet, path: "/trailers", statusCode: http.StatusOK, body: "trailed", chunked: true, trailer: "abc123"}, {name: "HEAD", method: http.MethodHead, path: "/fixed", statusCode: http.StatusOK}, From ac194e51f195c36460755f478dc968b403fe9d85 Mon Sep 17 00:00:00 2001 From: Hariharan Thavachelvam <164553783+thavaahariharangit@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:59:26 +0000 Subject: [PATCH 12/12] Trigger CI