Skip to content

Commit ed78cb8

Browse files
committed
feat(proxy): add privacy header mode
1 parent 2e72dd2 commit ed78cb8

13 files changed

Lines changed: 431 additions & 7 deletions

‎.env.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ PROXY_NETWORK=auto
1616
# DANGEROUS: temporarily log full URLs, upstream errors, and rejected credentials.
1717
PROXY_LOG_SENSITIVE_DATA=false
1818

19+
# Best-effort HTTP header privacy. See README for HTTPS/WebRTC limitations.
20+
PROXY_PRIVACY_MODE=false
21+
1922
# Resource limits.
2023
PROXY_MAX_CONNECTIONS=1024
2124
PROXY_MAX_CONNECTIONS_PER_IP=128

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ A secure HTTP/HTTPS proxy server in Go with Basic authentication, TLS support, a
1616

1717
- HTTP and HTTPS proxy modes
1818
- HTTP/2 CONNECT multiplexing in HTTPS mode with HTTP/1.1 fallback
19+
- Optional privacy mode for stripping client-IP and proxy-chain headers
1920
- Basic authentication
2021
- TLS with configurable certificates
2122
- Upstream proxy chaining (proxy chain support)
@@ -75,6 +76,7 @@ Any of these override the corresponding YAML field:
7576
| `PROXY_UPSTREAM_PROXY` | `upstream_proxy` (URL or `direct`) |
7677
| `PROXY_NETWORK` | `network` (`auto` / `tcp4` / `tcp6`) |
7778
| `PROXY_LOG_SENSITIVE_DATA` | `log_sensitive_data` (`true` exposes full request URLs, upstream errors, and rejected credentials in logs) |
79+
| `PROXY_PRIVACY_MODE` | `privacy_mode` (best-effort HTTP header privacy) |
7880
| `PROXY_MAX_CONNECTIONS` | `max_connections` |
7981
| `PROXY_MAX_CONNECTIONS_PER_IP` | `max_connections_per_ip` |
8082
| `PROXY_MAX_TUNNELS` | `max_tunnels` |
@@ -166,6 +168,7 @@ cert_path: ""
166168
key_path: ""
167169
network: auto
168170
log_sensitive_data: false
171+
privacy_mode: false
169172
max_connections: 1024
170173
max_connections_per_ip: 128
171174
max_tunnels: 256
@@ -208,6 +211,7 @@ The example listens on localhost. Set `proxy_addr` to `0.0.0.0:8080` only when t
208211
| `upstream_proxy` | Upstream proxy URL, `direct`, or empty to use proxy environment variables |
209212
| `network` | Outbound address family: `auto`, `tcp4`, or `tcp6` |
210213
| `log_sensitive_data` | Log full request URLs, upstream errors, and rejected Basic Auth username/password; disabled by default |
214+
| `privacy_mode` | Strip client-IP/forwarding headers and suppress `Via` for proxy-visible HTTP metadata |
211215
| `max_connections` | Maximum simultaneous client TCP connections |
212216
| `max_connections_per_ip` | Maximum simultaneous client connections per source IP |
213217
| `max_tunnels` | Maximum simultaneous CONNECT tunnels |
@@ -238,6 +242,12 @@ Timeout values use Go duration syntax, for example `500ms`, `10s`, or `2m`. Unkn
238242

239243
By default, request URL userinfo and query parameters are removed from logs, upstream errors are reduced to their HTTP category, and rejected Basic Auth credentials are not logged. For temporary diagnostics, set `log_sensitive_data: true` or `PROXY_LOG_SENSITIVE_DATA=true`. This can expose passwords and tokens in plaintext logs; disable it immediately after debugging.
240244

245+
### Privacy mode
246+
247+
Set `privacy_mode: true` or `PROXY_PRIVACY_MODE=true` to remove known client-address forwarding metadata from ordinary HTTP requests and trailers, including RFC `Forwarded`, `Via`, `X-Forwarded-*`, `X-Real-IP`, `True-Client-IP`, CDN connecting-IP headers, and common vendor equivalents. The proxy also omits its own `Via` header and does not forward a `Via` chain to an upstream proxy.
248+
249+
This is best-effort header privacy, not full anonymity. HTTPS/WSS traffic inside CONNECT is encrypted end-to-end, so the proxy cannot remove cookies, authorization headers, browser fingerprints, or application identifiers without acting as a TLS interception proxy. Browser traffic outside HTTP/HTTPS/WS/WSS, including WebRTC or other UDP paths, can bypass a web proxy. Sites can also classify the server's public IP as hosting, VPN, or proxy infrastructure.
250+
241251
### Resource limits and destination policy
242252
243253
Connection and CONNECT limits are enforced globally and per source IP. Excess TCP connections are closed immediately; excess tunnels and rate-limited authentication attempts receive `429 Too Many Requests`. A tunnel is closed when no bytes flow in either direction for `tunnel_idle_timeout`. Unless explicitly overridden, the HTTP/2 SETTINGS stream limit is derived from the lower tunnel limit so clients do not open streams that the proxy would immediately reject.

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.5.1
1+
0.6.0

‎config.docker.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ cert_path: ""
66
key_path: ""
77
network: auto
88
log_sensitive_data: false
9+
privacy_mode: false
910
max_connections: 1024
1011
max_connections_per_ip: 128
1112
max_tunnels: 256

‎config.example.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ cert_path: ./cert.pem
66
key_path: ./key.pem
77
network: auto
88
log_sensitive_data: false
9+
privacy_mode: false
910
max_connections: 1024
1011
max_connections_per_ip: 128
1112
max_tunnels: 256

‎docker-compose.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ services:
1414
PROXY_UPSTREAM_PROXY: ${PROXY_UPSTREAM_PROXY:-}
1515
PROXY_NETWORK: ${PROXY_NETWORK:-auto}
1616
PROXY_LOG_SENSITIVE_DATA: "${PROXY_LOG_SENSITIVE_DATA:-false}"
17+
PROXY_PRIVACY_MODE: "${PROXY_PRIVACY_MODE:-false}"
1718
PROXY_MAX_CONNECTIONS: "${PROXY_MAX_CONNECTIONS:-1024}"
1819
PROXY_MAX_CONNECTIONS_PER_IP: "${PROXY_MAX_CONNECTIONS_PER_IP:-128}"
1920
PROXY_MAX_TUNNELS: "${PROXY_MAX_TUNNELS:-256}"
@@ -50,6 +51,7 @@ services:
5051
PROXY_UPSTREAM_PROXY: ${PROXY_UPSTREAM_PROXY:-}
5152
PROXY_NETWORK: ${PROXY_NETWORK:-auto}
5253
PROXY_LOG_SENSITIVE_DATA: "${PROXY_LOG_SENSITIVE_DATA:-false}"
54+
PROXY_PRIVACY_MODE: "${PROXY_PRIVACY_MODE:-false}"
5355
PROXY_MAX_CONNECTIONS: "${PROXY_MAX_CONNECTIONS:-1024}"
5456
PROXY_MAX_CONNECTIONS_PER_IP: "${PROXY_MAX_CONNECTIONS_PER_IP:-128}"
5557
PROXY_MAX_TUNNELS: "${PROXY_MAX_TUNNELS:-256}"

‎internal/config/config.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type Config struct {
5050
UpstreamProxy string `yaml:"upstream_proxy"`
5151
Network string `yaml:"network"`
5252
LogSensitiveData bool `yaml:"log_sensitive_data"`
53+
PrivacyMode bool `yaml:"privacy_mode"`
5354
MaxConnections int `yaml:"max_connections"`
5455
MaxConnectionsPerIP int `yaml:"max_connections_per_ip"`
5556
MaxTunnels int `yaml:"max_tunnels"`
@@ -87,6 +88,7 @@ func defaultConfig() Config {
8788
ProxyAddr: "127.0.0.1:8080",
8889
Proto: "http",
8990
Network: "auto",
91+
PrivacyMode: false,
9092
MaxConnections: 1024,
9193
MaxConnectionsPerIP: 128,
9294
MaxTunnels: 256,
@@ -161,7 +163,8 @@ func decodeConfig(r io.Reader) (Config, error) {
161163
//
162164
// PROXY_ADDR, PROXY_USERNAME, PROXY_PASSWORD, PROXY_PROTO,
163165
// PROXY_CERT_PATH, PROXY_KEY_PATH, PROXY_UPSTREAM_PROXY, PROXY_NETWORK,
164-
// PROXY_LOG_SENSITIVE_DATA, PROXY_MAX_CONNECTIONS,
166+
// PROXY_LOG_SENSITIVE_DATA, PROXY_PRIVACY_MODE,
167+
// PROXY_MAX_CONNECTIONS,
165168
// PROXY_MAX_CONNECTIONS_PER_IP, PROXY_MAX_TUNNELS,
166169
// PROXY_MAX_TUNNELS_PER_IP, PROXY_HTTP2_MAX_CONCURRENT_STREAMS,
167170
// PROXY_MAX_IDLE_CONNS,
@@ -207,6 +210,7 @@ func applyEnvOverrides(c *Config) error {
207210
dest *bool
208211
}{
209212
{"PROXY_LOG_SENSITIVE_DATA", &c.LogSensitiveData},
213+
{"PROXY_PRIVACY_MODE", &c.PrivacyMode},
210214
{"PROXY_ALLOW_PRIVATE_DESTINATIONS", &c.AllowPrivateDestinations},
211215
}
212216
for _, override := range boolOverrides {

‎internal/config/config_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func TestDecodeConfig(t *testing.T) {
6060
"proxy_addr: 0.0.0.0:3128",
6161
"network: tcp4",
6262
"log_sensitive_data: true",
63+
"privacy_mode: true",
6364
"max_connections: 200",
6465
"max_connections_per_ip: 20",
6566
"max_tunnels: 50",
@@ -97,6 +98,9 @@ func TestDecodeConfig(t *testing.T) {
9798
if !got.LogSensitiveData {
9899
t.Error("LogSensitiveData = false, want true")
99100
}
101+
if !got.PrivacyMode {
102+
t.Error("PrivacyMode = false, want true")
103+
}
100104
if !got.AllowPrivateDestinations {
101105
t.Error("AllowPrivateDestinations = false, want true")
102106
}
@@ -681,6 +685,42 @@ func TestApplyEnvOverridesParsesLogSensitiveData(t *testing.T) {
681685
}
682686
}
683687

688+
func TestApplyEnvOverridesParsesPrivacyMode(t *testing.T) {
689+
tests := []struct {
690+
name string
691+
value string
692+
want bool
693+
wantError bool
694+
}{
695+
{name: "enabled", value: "true", want: true},
696+
{name: "disabled", value: "false", want: false},
697+
{name: "invalid", value: "sometimes", wantError: true},
698+
}
699+
700+
for _, test := range tests {
701+
t.Run(test.name, func(t *testing.T) {
702+
clearProxyEnvironment(t)
703+
t.Setenv("PROXY_PRIVACY_MODE", test.value)
704+
config := defaultConfig()
705+
config.PrivacyMode = !test.want
706+
707+
err := applyEnvOverrides(&config)
708+
if test.wantError {
709+
if err == nil || !strings.Contains(err.Error(), "PROXY_PRIVACY_MODE") {
710+
t.Fatalf("applyEnvOverrides() error = %v, want boolean parse error", err)
711+
}
712+
return
713+
}
714+
if err != nil {
715+
t.Fatalf("applyEnvOverrides() error = %v", err)
716+
}
717+
if config.PrivacyMode != test.want {
718+
t.Fatalf("PrivacyMode = %t, want %t", config.PrivacyMode, test.want)
719+
}
720+
})
721+
}
722+
}
723+
684724
func TestApplyEnvOverridesParsesResourceLimits(t *testing.T) {
685725
clearProxyEnvironment(t)
686726
for name, value := range map[string]string{
@@ -876,6 +916,7 @@ func clearProxyEnvironment(t *testing.T) {
876916
"PROXY_UPSTREAM_PROXY",
877917
"PROXY_NETWORK",
878918
"PROXY_LOG_SENSITIVE_DATA",
919+
"PROXY_PRIVACY_MODE",
879920
"PROXY_MAX_CONNECTIONS",
880921
"PROXY_MAX_CONNECTIONS_PER_IP",
881922
"PROXY_MAX_TUNNELS",

‎internal/proxy/headers.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,61 @@ package proxy
22

33
import (
44
"fmt"
5+
"io"
56
"net/http"
67
"strings"
78
)
89

910
// viaReceivedBy is a stable pseudonym, as permitted for the Via received-by value.
1011
const viaReceivedBy = "https_proxy"
1112

13+
var privacyHeaderNames = map[string]struct{}{
14+
"cf-connecting-ip": {},
15+
"client-ip": {},
16+
"do-connecting-ip": {},
17+
"fastly-client-ip": {},
18+
"fly-client-ip": {},
19+
"forwarded": {},
20+
"remote-addr": {},
21+
"true-client-ip": {},
22+
"via": {},
23+
"x-appengine-user-ip": {},
24+
"x-azure-clientip": {},
25+
"x-client-ip": {},
26+
"x-cluster-client-ip": {},
27+
"x-envoy-external-address": {},
28+
"x-forwarded": {},
29+
"x-originating-ip": {},
30+
"x-original-forwarded-for": {},
31+
"x-proxyuser-ip": {},
32+
"x-real-ip": {},
33+
"x-remote-addr": {},
34+
"x-remote-ip": {},
35+
}
36+
37+
func removePrivacyHeaders(header http.Header) {
38+
for name := range header {
39+
normalized := strings.ToLower(name)
40+
_, exactMatch := privacyHeaderNames[normalized]
41+
if exactMatch || strings.HasPrefix(normalized, "x-forwarded-") {
42+
delete(header, name)
43+
}
44+
}
45+
}
46+
47+
type privacyTrailerBody struct {
48+
io.ReadCloser
49+
trailer http.Header
50+
}
51+
52+
func (b *privacyTrailerBody) Read(buffer []byte) (int, error) {
53+
read, err := b.ReadCloser.Read(buffer)
54+
if err == io.EOF {
55+
removePrivacyHeaders(b.trailer)
56+
}
57+
return read, err
58+
}
59+
1260
func appendVia(header http.Header, protoMajor, protoMinor int) {
1361
header.Add("Via", fmt.Sprintf("%d.%d %s", protoMajor, protoMinor, viaReceivedBy))
1462
}

‎internal/proxy/http2_connect_test.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net"
1111
"net/http"
1212
"net/http/httptest"
13+
"net/url"
1314
"os"
1415
"path/filepath"
1516
"sync"
@@ -141,6 +142,70 @@ func TestAuthenticatedHTTP2ConnectTunnel(t *testing.T) {
141142
}
142143
}
143144

145+
func TestHTTP2ForwardsPlainHTTPWithPrivacyMode(t *testing.T) {
146+
originResult := make(chan error, 1)
147+
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148+
if r.URL.Path != "/privacy" || r.URL.RawQuery != "check=yes" {
149+
originResult <- fmt.Errorf("origin URL = %s", r.URL.String())
150+
return
151+
}
152+
for _, name := range []string{"Forwarded", "Via", "X-Forwarded-For", "X-Real-IP"} {
153+
if value := r.Header.Get(name); value != "" {
154+
originResult <- fmt.Errorf("origin %s = %q", name, value)
155+
return
156+
}
157+
}
158+
if value := r.Header.Get("X-Keep"); value != "keep-value" {
159+
originResult <- fmt.Errorf("origin X-Keep = %q", value)
160+
return
161+
}
162+
w.WriteHeader(http.StatusNoContent)
163+
originResult <- nil
164+
}))
165+
t.Cleanup(origin.Close)
166+
originURL, err := url.Parse(origin.URL)
167+
if err != nil {
168+
t.Fatalf("parse origin URL: %v", err)
169+
}
170+
171+
cfg := config.Default()
172+
cfg.AllowPrivateDestinations = true
173+
cfg.BlockedDestinationPorts = nil
174+
cfg.PrivacyMode = true
175+
proxyHandler, err := NewServer(cfg)
176+
if err != nil {
177+
t.Fatalf("NewServer() error = %v", err)
178+
}
179+
180+
var proxyConnections atomic.Int32
181+
proxyTLS := newHTTP2TestServer(proxyHandler, &proxyConnections)
182+
t.Cleanup(proxyTLS.Close)
183+
client := proxyTLS.Client()
184+
client.Timeout = testIOTimeout
185+
186+
request, err := http.NewRequest(http.MethodGet, proxyTLS.URL+"/privacy?check=yes", nil)
187+
if err != nil {
188+
t.Fatalf("create HTTP/2 proxy request: %v", err)
189+
}
190+
request.Host = originURL.Host
191+
request.Header.Set("Forwarded", "for=192.0.2.40")
192+
request.Header.Set("Via", "1.1 client-proxy")
193+
request.Header.Set("X-Forwarded-For", "192.0.2.41")
194+
request.Header.Set("X-Real-IP", "192.0.2.42")
195+
request.Header.Set("X-Keep", "keep-value")
196+
response, err := client.Do(request)
197+
if err != nil {
198+
t.Fatalf("perform HTTP/2 proxy request: %v", err)
199+
}
200+
defer func() { _ = response.Body.Close() }()
201+
if response.ProtoMajor != 2 || response.StatusCode != http.StatusNoContent {
202+
t.Fatalf("proxy response = %s %d, want HTTP/2 204", response.Proto, response.StatusCode)
203+
}
204+
if err := <-originResult; err != nil {
205+
t.Fatalf("origin: %v", err)
206+
}
207+
}
208+
144209
func TestHTTP2ConnectMultiplexesMoreThanHTTP1ProxyLimit(t *testing.T) {
145210
const tunnelCount = 40
146211

0 commit comments

Comments
 (0)