diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e78e84..10b3f84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ mise run dev | Flag | Env var | Default | Description | |---|---|---|---| | `--addr` | `UBLPROXY_ADDR` | `0.0.0.0` | Address to listen on | -| `--http-port` | `UBLPROXY_HTTP_PORT` | `8080` | HTTP port for setup page and CA certificate download | +| `--http-port` | `UBLPROXY_HTTP_PORT` | `8080` | HTTP port for setup page, CA certificate download, and mobile proxy | | `--https-port` | `UBLPROXY_HTTPS_PORT` | `8443` | HTTPS port for proxy, portal, and API | | `--hostname` | `UBLPROXY_HOSTNAME` | `localhost` | Portal hostname for WebAuthn and TLS cert (must be a domain, not an IP) | | `--ca-dir` | `UBLPROXY_CA_DIR` | `~/.ublproxy/` | Directory for CA certificate and key | @@ -72,6 +72,12 @@ curl --proxy https://127.0.0.1:8443 --proxy-cacert ~/.ublproxy/ca.crt --cacert ~ # HTTPS (skip certificate verification — quick and dirty) curl --proxy https://127.0.0.1:8443 --proxy-insecure -k https://example.com + +# Mobile proxy (plain HTTP CONNECT — how iOS/Android connect) +curl --proxy http://127.0.0.1:8080 --cacert ~/.ublproxy/ca.crt https://example.com + +# Fetch the mobile PAC file +curl http://127.0.0.1:8080/mobile.pac ``` ### Verifying request blocking @@ -179,7 +185,7 @@ Proxy code lives in `package main` in the project root. The `pkg/` directory con | `proxy.go` | Proxy handler, baseline/per-user rule loading, request dispatch | | `http.go` | Plain HTTP request forwarding, hop-by-hop header stripping | | `connect.go` | CONNECT method handling, TLS MITM, request forwarding | -| `portal.go` | Portal and setup page serving (embeds static HTML) | +| `portal.go` | Portal, setup page, mobile PAC, and QR code serving (embeds static HTML) | | `portal_https.go` | HTTPS listener, TLS termination, request routing | | `api.go` | API handler, routing, CORS, and auth middleware | | `api_auth.go` | WebAuthn registration and login API endpoints | @@ -204,7 +210,7 @@ Proxy code lives in `package main` in the project root. The `pkg/` directory con | `static/activity.html` | Activity feed (filtered, auto-refresh) | | `static/shared.css` | Shared design system (CSS custom properties, nav, cards) | | `static/shared.js` | Shared auth, WebAuthn, nav helpers | -| `static/setup.html` | Setup page for CA certificate installation | +| `static/setup.html` | Setup page for CA certificate installation (desktop, iOS, Android) | | `static/bootstrap.js` | Injected bootstrap script (keyboard shortcut, picker loader) | | `static/picker.js` | Element picker UI (Shadow DOM isolated) | | `scripts/build` | Build task | diff --git a/DECISIONS.md b/DECISIONS.md index d44d3ab..ce0f4d0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -59,3 +59,4 @@ - 2026-02-27 m+git@andri.dk — In-memory activity log (ring buffer, capacity 1000). Records blocked, passthrough, and element-hidden events at key proxy decision points. API endpoints: `GET /api/activity?limit=N` (recent events) and `GET /api/activity/stats` (counts by type). Ephemeral — resets on restart. No database storage, no disk I/O overhead. - 2026-02-27 m+git@andri.dk — Multi-page portal UI replacing single-page `portal.html`. Four pages: Dashboard (auth + stats), Rules (CRUD + search + syntax help), Subscriptions (default lists with badges + user lists + suggested), Activity (filtered feed, auto-refresh). Shared styles in `shared.css` and shared auth/nav logic in `shared.js`. All files embedded via `go:embed`. Vanilla HTML/CSS/JS — no framework, no build step. - 2026-02-27 m+git@andri.dk — Static files served from `/static/*` using an embedded file map in `portal.go`. Files include `shared.css` and `shared.js`. 5-minute cache with `Cache-Control: public, max-age=300`. +- 2026-02-27 m+git@andri.dk — Mobile device support (iOS/Android). iOS and Android do not support the `HTTPS` PAC proxy type, so the HTTP port (8080) now also accepts CONNECT tunnels and HTTP forwarding for mobile clients. A separate `/mobile.pac` file returns `PROXY host:port` (plain HTTP) instead of `HTTPS host:port`. The bootstrap script (element picker + session token) is not injected on plain HTTP connections to prevent leaking the session token over unencrypted traffic — detection uses `r.TLS == nil`. CSS element hiding and resource stripping still apply normally. Added `github.com/skip2/go-qrcode` (pure Go, zero transitive runtime deps) for a QR code on the setup page linking to the HTTP setup URL. diff --git a/QUICK_START.md b/QUICK_START.md index 94002dc..72f4007 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -64,7 +64,7 @@ volumes: The proxy listens on two ports: -- **HTTP** (default `8080`) — Setup page and CA certificate download +- **HTTP** (default `8080`) — Setup page, CA certificate download, and mobile proxy (plain HTTP CONNECT for iOS/Android) - **HTTPS** (default `8443`) — Proxy, management portal, and API ## Blocklists @@ -97,7 +97,7 @@ Authenticated users can add their own subscriptions (EasyList, EasyPrivacy, etc. The proxy generates a CA certificate on first run. Your browser must trust this certificate for HTTPS filtering to work. 1. Visit `http://:/` (e.g. `http://localhost:8080/`) to download the CA certificate -2. Install and trust it on your platform — the setup page has detailed instructions for macOS, Linux, Windows, and Firefox +2. Install and trust it on your platform — the setup page has detailed instructions for macOS, iOS, Android, Linux, Windows, and Firefox 3. **Restart your browser** after trusting the certificate — browsers cache certificate trust state and won't pick up changes until restarted When running with Docker, the CA certificate persists in the mounted `/data` volume. Download it from the HTTP setup page or copy it directly from the volume (`ca.crt`). @@ -108,6 +108,16 @@ The setup page at `http://:/` provides a PAC (Proxy Auto-Config Alternatively, set `https://:` as an HTTPS proxy manually. +### Mobile devices (iOS / Android) + +Mobile devices cannot use the HTTPS proxy because iOS and Android don't support the `HTTPS` PAC proxy type. Use the mobile-specific PAC URL instead: + +1. Open `http://:/` on your phone (scan the QR code on the setup page) +2. Download and install the CA certificate (see the setup page for platform-specific steps) +3. Configure your Wi-Fi proxy to **Automatic** with the URL: `http://:/mobile.pac` + +The mobile PAC file routes traffic through the plain HTTP proxy on port 8080. Ad blocking and element hiding work identically to the desktop proxy. The element picker is not available on mobile connections. + ## Custom rules ### 1. Register an account @@ -184,7 +194,7 @@ All flags can also be set via environment variables. Environment variables take | Flag | Env var | Default | Description | |------|---------|---------|-------------| | `--addr` | `UBLPROXY_ADDR` | `0.0.0.0` | Address to listen on | -| `--http-port` | `UBLPROXY_HTTP_PORT` | `8080` | HTTP port for setup page and CA certificate download | +| `--http-port` | `UBLPROXY_HTTP_PORT` | `8080` | HTTP port for setup page, CA certificate download, and mobile proxy | | `--https-port` | `UBLPROXY_HTTPS_PORT` | `8443` | HTTPS port for proxy, portal, and API | | `--hostname` | `UBLPROXY_HOSTNAME` | `localhost` | Portal hostname for WebAuthn and TLS cert (must be a domain, not an IP) | | `--ca-dir` | `UBLPROXY_CA_DIR` | `~/.ublproxy` | Directory for CA certificate and key | diff --git a/README.md b/README.md index 1800da3..77b9469 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Add your own blocking and element-hiding rules using adblock filter syntax, or u ### Encrypted, all the way -The proxy listens on HTTPS. Traffic between browser and proxy is encrypted, and the proxy generates per-host TLS certificates on the fly using its own CA. +Desktop browsers connect to the proxy over HTTPS. The proxy generates per-host TLS certificates on the fly using its own CA. Mobile devices (iOS/Android) connect over plain HTTP because they don't support HTTPS proxy connections — the MITM tunnel within the connection is still TLS-encrypted. ### Strips away scripts, images and embeds @@ -46,6 +46,7 @@ It's important to be aware, that by using this solution, you're essentially decr - **No re-compression**: After decompressing gzip for CSS injection, HTML is served uncompressed to the client. This is fine when the proxy runs on localhost. - **Cert cache**: Generated TLS certificates are cached indefinitely with no eviction. Certificates have 24-hour validity but expired entries are never cleaned up. Fine for personal use. - **Session-to-IP mapping**: Sessions are bound to client IP. Multiple users behind the same NAT IP share a single session slot (last login wins). +- **Mobile proxy connection is unencrypted**: iOS and Android don't support HTTPS proxy connections. Mobile devices use a plain HTTP CONNECT proxy on port 8080. The CONNECT metadata (target hostname) is visible on the LAN, though the tunneled content is TLS-encrypted. The element picker is disabled on mobile connections to avoid leaking the session token. ## References diff --git a/connect.go b/connect.go index 22fee2d..3b6e4f5 100644 --- a/connect.go +++ b/connect.go @@ -70,9 +70,12 @@ func (p *proxyHandler) handleConnect(w http.ResponseWriter, r *http.Request) { // Clear the deadline after successful handshake clientConn.SetDeadline(time.Time{}) - // Extract client IP for script injection (from the original CONNECT request) + // Extract client IP for script injection (from the original CONNECT request). + // If the outer connection is plain HTTP (r.TLS == nil), mark as insecure + // so the bootstrap script (which contains the session token) is not injected. cIP, _, _ := net.SplitHostPort(r.RemoteAddr) - p.proxyTLSRequests(tlsClientConn, host, port, cIP) + insecure := r.TLS == nil + p.proxyTLSRequests(tlsClientConn, host, port, cIP, insecure) } // tunnelPassthrough establishes a transparent TCP tunnel between the client @@ -109,7 +112,9 @@ func (p *proxyHandler) tunnelPassthrough(w http.ResponseWriter, r *http.Request, // proxyTLSRequests reads HTTP requests from the intercepted client TLS // connection and forwards them to the upstream server. Supports keep-alive // by looping until the client closes the connection or an error occurs. -func (p *proxyHandler) proxyTLSRequests(clientTLS *tls.Conn, host, port, clientIP string) { +// When insecure is true the outer proxy connection is plain HTTP, so the +// bootstrap script (which embeds the session token) is not injected. +func (p *proxyHandler) proxyTLSRequests(clientTLS *tls.Conn, host, port, clientIP string, insecure bool) { clientReader := bufio.NewReader(clientTLS) for { @@ -183,7 +188,7 @@ func (p *proxyHandler) proxyTLSRequests(clientTLS *tls.Conn, host, port, clientI // Replace ad elements in HTML responses (skip HEAD — no body to modify) if req.Method != http.MethodHead { - if modified, ok := p.applyElementHiding(resp, host, clientIP); ok { + if modified, ok := p.applyElementHiding(resp, host, clientIP, insecure); ok { resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(modified)) resp.ContentLength = int64(len(modified)) diff --git a/elemhide_inject.go b/elemhide_inject.go index 65c183f..93e0712 100644 --- a/elemhide_inject.go +++ b/elemhide_inject.go @@ -70,10 +70,11 @@ func (sc srcBlockContext) resolveSrc(src string) string { // elements are never removed from the DOM to avoid stripping legitimate page // content that happens to match generic selectors. // The bootstrap script for the element picker is injected when a session -// exists for the client IP. +// exists for the client IP, unless insecure is true (plain HTTP proxy +// connection) — the token must not be sent over unencrypted connections. // Returns the modified body and true, or nil and false if unmodified. // Handles gzip and brotli compressed responses transparently. -func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP string) ([]byte, bool) { +func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP string, insecure bool) ([]byte, bool) { contentType := resp.Header.Get("Content-Type") if !strings.Contains(contentType, "text/html") { return nil, false @@ -95,8 +96,13 @@ func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP st hasURLRules := (baseline != nil && (baseline.HostCount() > 0 || baseline.RuleCount() > 0)) || (userRS != nil && (userRS.HostCount() > 0 || userRS.RuleCount() > 0)) - // Generate bootstrap script tag (empty string if no session) - scriptTag := p.bootstrapScriptTag(clientIP, host) + // Generate bootstrap script tag (empty string if no session). + // Skip on insecure (plain HTTP) connections to avoid leaking the + // session token over unencrypted traffic. + var scriptTag string + if !insecure { + scriptTag = p.bootstrapScriptTag(clientIP, host) + } // Nothing to do if there are no rules AND no script to inject if baselineEH == nil && userEH == nil && !hasURLRules && scriptTag == "" { diff --git a/go.mod b/go.mod index eb1cec3..63d48be 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index 3bf249f..8a29f85 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= diff --git a/http.go b/http.go index 4084f75..7da3dd7 100644 --- a/http.go +++ b/http.go @@ -67,9 +67,12 @@ func (p *proxyHandler) handleHTTP(w http.ResponseWriter, r *http.Request) { } defer resp.Body.Close() - // Replace ad elements in HTML responses (skip HEAD — no body to modify) + // Replace ad elements in HTML responses (skip HEAD — no body to modify). + // If the proxy connection is plain HTTP (r.TLS == nil), skip the + // bootstrap script injection to avoid leaking the session token. + insecure := r.TLS == nil if r.Method != http.MethodHead { - if modified, ok := p.applyElementHiding(resp, r.URL.Hostname(), clientIPFromRequest(r)); ok { + if modified, ok := p.applyElementHiding(resp, r.URL.Hostname(), clientIPFromRequest(r), insecure); ok { copyHeaders(w.Header(), resp.Header) removeHopByHopHeaders(w.Header()) w.Header().Del("Content-Length") diff --git a/inject_test.go b/inject_test.go index bf91633..212effd 100644 --- a/inject_test.go +++ b/inject_test.go @@ -83,7 +83,7 @@ func TestScriptInjectionInHTML(t *testing.T) { Body: io.NopCloser(strings.NewReader(htmlBody)), } - modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1") + modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) if !ok { t.Fatal("expected modification") } @@ -122,7 +122,7 @@ func TestNoScriptInjectionWithoutSession(t *testing.T) { } // No rules and no session -> no modification - _, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1") + _, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) if ok { t.Error("should not modify HTML when there's no session and no rules") } @@ -143,7 +143,7 @@ func TestNoScriptInjectionForNonHTML(t *testing.T) { Body: io.NopCloser(strings.NewReader(`{"data": true}`)), } - _, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1") + _, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) if ok { t.Error("should not modify non-HTML responses") } @@ -173,7 +173,7 @@ func TestScriptInjectionWithGzip(t *testing.T) { Body: io.NopCloser(&buf), } - modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1") + modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) if !ok { t.Fatal("expected modification for gzipped HTML") } @@ -204,7 +204,7 @@ func TestScriptInjectionWithRules(t *testing.T) { Body: io.NopCloser(strings.NewReader(htmlBody)), } - modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1") + modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) if !ok { t.Fatal("expected modification") } diff --git a/main.go b/main.go index 2e8fb31..13e94b5 100644 --- a/main.go +++ b/main.go @@ -132,9 +132,24 @@ func run(_ context.Context, cmd *cli.Command) error { portalH := &portalHandler{proxy: handler, api: api} go startPortalHTTPS(httpsAddr, hostname, extraIPs, certs, portalH) + // For the HTTP origin, prefer the LAN IP over "localhost" since mobile + // devices need a routable address to reach the proxy. + httpHost := hostname + if httpHost == "localhost" && len(extraIPs) > 0 { + httpHost = extraIPs[0].String() + } + httpOrigin := fmt.Sprintf("http://%s:%d", httpHost, httpPort) + handler.httpOrigin = httpOrigin + httpAddr := fmt.Sprintf("%s:%d", addr, httpPort) - setupH := &setupHandler{caCertPEM: caCertPEM, portalOrigin: portalOrigin} + setupH := &setupHandler{ + proxy: handler, + caCertPEM: caCertPEM, + portalOrigin: portalOrigin, + httpOrigin: httpOrigin, + } fmt.Fprintf(os.Stderr, "ublproxy setup page on http://%s\n", httpAddr) + fmt.Fprintf(os.Stderr, "ublproxy mobile proxy on %s\n", httpOrigin) fmt.Fprintf(os.Stderr, "ublproxy proxy+portal on %s\n", portalOrigin) if err := http.ListenAndServe(httpAddr, setupH); err != nil { diff --git a/portal.go b/portal.go index e55e0b8..1e32a3c 100644 --- a/portal.go +++ b/portal.go @@ -5,6 +5,8 @@ import ( "html/template" "net/http" "net/url" + + qrcode "github.com/skip2/go-qrcode" ) //go:embed static/portal.html @@ -53,14 +55,28 @@ func serveStaticFile(w http.ResponseWriter, path string) bool { return true } -// setupHandler serves the HTTP-only setup page and CA certificate. -// No proxy, no API — those require TLS on the HTTPS port. +// setupHandler serves the HTTP setup page, CA certificate, and mobile PAC +// file. It also accepts proxy traffic (CONNECT tunnels and HTTP forwarding) +// over plain HTTP for mobile devices that cannot use an HTTPS proxy. +// The API is not served here — it requires TLS on the HTTPS port. type setupHandler struct { + proxy *proxyHandler caCertPEM []byte portalOrigin string + httpOrigin string } func (s *setupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Proxy: CONNECT tunnels (mobile devices send HTTPS requests via HTTP proxy) + if r.Method == http.MethodConnect { + s.proxy.handleConnect(w, r) + return + } + // Proxy: HTTP forward (absolute-URI requests through the proxy) + if r.URL.Host != "" { + s.proxy.handleHTTP(w, r) + return + } if r.URL.Path == "/ca.crt" { w.Header().Set("Content-Type", "application/x-pem-file") w.Header().Set("Content-Disposition", `attachment; filename="ublproxy-ca.crt"`) @@ -72,10 +88,18 @@ func (s *setupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handlePAC(w, r) return } + if r.URL.Path == "/mobile.pac" { + s.handleMobilePAC(w, r) + return + } + if r.URL.Path == "/qr.png" { + s.handleQR(w, r) + return + } if r.URL.Path == "/" || r.URL.Path == "/setup" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - setupTmpl.Execute(w, struct{ PortalURL string }{s.portalOrigin}) + setupTmpl.Execute(w, setupData{PortalURL: s.portalOrigin, HttpOrigin: s.httpOrigin}) return } http.NotFound(w, r) @@ -93,6 +117,11 @@ func (s *setupHandler) handlePAC(w http.ResponseWriter, r *http.Request) { pacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) } +type setupData struct { + PortalURL string + HttpOrigin string +} + var pacTmpl = template.Must(template.New("pac").Parse(`function FindProxyForURL(url, host) { if (isPlainHostName(host) || host === "localhost" || host === "127.0.0.1" || host === "::1") { @@ -102,6 +131,39 @@ var pacTmpl = template.Must(template.New("pac").Parse(`function FindProxyForURL( } `)) +// mobilePacTmpl returns PROXY (plain HTTP) instead of HTTPS. iOS and Android +// do not support the HTTPS PAC proxy type, so mobile devices use this file. +var mobilePacTmpl = template.Must(template.New("mobilepac").Parse(`function FindProxyForURL(url, host) { + if (isPlainHostName(host) || + host === "localhost" || host === "127.0.0.1" || host === "::1") { + return "DIRECT"; + } + return "PROXY {{.ProxyHost}}"; +} +`)) + +func (s *setupHandler) handleMobilePAC(w http.ResponseWriter, r *http.Request) { + parsed, err := url.Parse(s.httpOrigin) + if err != nil { + http.Error(w, "misconfigured http origin", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") + mobilePacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) +} + +func (s *setupHandler) handleQR(w http.ResponseWriter, r *http.Request) { + png, err := qrcode.Encode(s.httpOrigin, qrcode.Medium, 256) + if err != nil { + http.Error(w, "failed to generate QR code", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.WriteHeader(http.StatusOK) + w.Write(png) +} + // handlePortal routes direct requests on the proxy listener (used by // proxyHandler.ServeHTTP when r.URL.Host is empty). In production the // proxy runs on the HTTPS port and direct requests go through @@ -116,6 +178,10 @@ func (p *proxyHandler) handlePortal(w http.ResponseWriter, r *http.Request) { p.handlePAC(w, r) return } + if r.URL.Path == "/mobile.pac" { + p.handleMobilePAC(w, r) + return + } if r.URL.Path == "/" || r.URL.Path == "/setup" { p.handleSetup(w, r) return @@ -133,6 +199,16 @@ func (p *proxyHandler) handlePAC(w http.ResponseWriter, r *http.Request) { pacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) } +func (p *proxyHandler) handleMobilePAC(w http.ResponseWriter, r *http.Request) { + parsed, err := url.Parse(p.httpOrigin) + if err != nil { + http.Error(w, "misconfigured http origin", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-ns-proxy-autoconfig") + mobilePacTmpl.Execute(w, struct{ ProxyHost string }{parsed.Host}) +} + func serveHTML(w http.ResponseWriter, content string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) @@ -158,7 +234,7 @@ func (p *proxyHandler) handlePortalActivity(w http.ResponseWriter, r *http.Reque func (p *proxyHandler) handleSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - setupTmpl.Execute(w, struct{ PortalURL string }{p.portalOrigin}) + setupTmpl.Execute(w, setupData{PortalURL: p.portalOrigin, HttpOrigin: p.httpOrigin}) } func (p *proxyHandler) handlePortalCACert(w http.ResponseWriter, r *http.Request) { diff --git a/portal_https.go b/portal_https.go index d147973..3c38b4f 100644 --- a/portal_https.go +++ b/portal_https.go @@ -36,6 +36,10 @@ func (h *portalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.proxy.handlePAC(w, r) return } + if r.URL.Path == "/mobile.pac" { + h.proxy.handleMobilePAC(w, r) + return + } if r.URL.Path == "/setup" { h.proxy.handleSetup(w, r) return diff --git a/proxy.go b/proxy.go index adfabb0..9ca5b52 100644 --- a/proxy.go +++ b/proxy.go @@ -24,6 +24,7 @@ type proxyHandler struct { sessions *sessionMap portalOrigin string + httpOrigin string // activityLog records recent proxy events for the activity feed. activityLog *ActivityLog diff --git a/proxy_test.go b/proxy_test.go index 51616da..cf9357e 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -2347,3 +2347,156 @@ func TestBlockedHostStillBlockedWithExceptions(t *testing.T) { t.Error("expected error for blocked HTTPS host, got nil") } } + +func TestMobilePAC(t *testing.T) { + env := startTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), nil) + + env.handler.httpOrigin = "http://192.168.1.100:8080" + + resp, err := http.Get(env.proxyURL + "/mobile.pac") + if err != nil { + t.Fatalf("GET /mobile.pac: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "application/x-ns-proxy-autoconfig" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/x-ns-proxy-autoconfig") + } + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + if !strings.Contains(bodyStr, "FindProxyForURL") { + t.Error("mobile PAC body does not contain FindProxyForURL function") + } + if !strings.Contains(bodyStr, "PROXY 192.168.1.100:8080") { + t.Errorf("mobile PAC body does not contain expected PROXY directive, got:\n%s", bodyStr) + } + if strings.Contains(bodyStr, "HTTPS") { + t.Error("mobile PAC body should not contain HTTPS directive") + } +} + +func TestHTTPPortConnect(t *testing.T) { + var receivedPath string + env := startTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.Write([]byte("hello from upstream")) + }), nil) + + // The test proxy server uses httptest.NewServer (plain HTTP), which is + // exactly the scenario we want: CONNECT over plain HTTP. + proxyURL, _ := url.Parse(env.proxyURL) + + // Create an HTTP client that uses our plain-HTTP proxy for HTTPS requests + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{ + RootCAs: env.caPool, + }, + }, + } + + resp, err := client.Get(env.httpsURL + "/test-path") + if err != nil { + t.Fatalf("GET through HTTP CONNECT: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if string(body) != "hello from upstream" { + t.Errorf("body = %q, want %q", body, "hello from upstream") + } + if receivedPath != "/test-path" { + t.Errorf("upstream received path = %q, want %q", receivedPath, "/test-path") + } +} + +func TestHTTPPortForward(t *testing.T) { + var receivedPath string + env := startTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.Write([]byte("forwarded response")) + }), nil) + + proxyURL, _ := url.Parse(env.proxyURL) + + // Use the proxy for plain HTTP requests (absolute-URI forwarding) + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + } + + resp, err := client.Get(env.httpURL + "/forward-test") + if err != nil { + t.Fatalf("GET through HTTP forward: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if string(body) != "forwarded response" { + t.Errorf("body = %q, want %q", body, "forwarded response") + } + if receivedPath != "/forward-test" { + t.Errorf("upstream received path = %q, want %q", receivedPath, "/forward-test") + } +} + +func TestNoBootstrapInjectionOnInsecureProxy(t *testing.T) { + sm := newSessionMap() + sm.Set("127.0.0.1", sessionEntry{Token: "secret-token", CredentialID: "cred-1"}) + + p := &proxyHandler{ + sessions: sm, + portalOrigin: "https://127.0.0.1:8443", + } + + htmlBody := `Test

Hello

` + resp := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, + Body: io.NopCloser(strings.NewReader(htmlBody)), + } + + // With insecure=true (plain HTTP proxy), bootstrap script should NOT be injected + modified, ok := p.applyElementHiding(resp, "example.com", "127.0.0.1", true) + if ok { + body := string(modified) + if strings.Contains(body, "secret-token") { + t.Error("session token must not be injected on insecure connections") + } + if strings.Contains(body, "