fix(forwarder): authenticate HTTP node with Proxy-Authorization

Node auth on the HTTP forwarder path read the origin credential
(Authorization) and challenged with 401/WWW-Authenticate. When the
forwarded backend also used Basic auth, the node consumed the credential
meant for the backend, so the two could not coexist.

Read the proxy credential (Proxy-Authorization) instead and challenge
with 407/Proxy-Authenticate. Authorization is left untouched so it
reaches the backend, and Proxy-Authorization is stripped before the
upstream write since it is hop-by-hop (RFC 9110 11.7.2).
This commit is contained in:
ginuerzh
2026-09-20 20:43:11 +08:00
parent 06197bd987
commit 34fbff3d3c
+38 -4
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"errors"
"io"
"net"
@@ -43,6 +44,28 @@ func borrowBodyPrefix(body io.ReadCloser, n int) (prefix []byte, restored io.Rea
return
}
// basicProxyAuth extracts the username and password from an HTTP Basic
// Proxy-Authorization header value. It mirrors net/http's Request.BasicAuth,
// but for the proxy credential (RFC 9110 §11.7.2) instead of the origin
// credential. It returns ok=false if the value is empty, does not use the
// Basic scheme, or is not valid base64; the password may contain colons since
// the username/password separator is the first colon.
func basicProxyAuth(proxyAuth string) (username, password string, ok bool) {
const prefix = "Basic "
if len(proxyAuth) < len(prefix) || !strings.EqualFold(proxyAuth[:len(prefix)], prefix) {
return "", "", false
}
c, err := base64.StdEncoding.DecodeString(proxyAuth[len(prefix):])
if err != nil {
return "", "", false
}
username, password, ok = strings.Cut(string(c), ":")
if !ok {
return "", "", false
}
return username, password, true
}
// HandleHTTP sniffs and proxies an HTTP connection. It reads the initial
// request, performs node selection via the configured hop, and forwards the
// request with HTTP keep-alive support.
@@ -454,13 +477,20 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
var respHeaderRewrites []chain.HTTPHeaderRewriteSettings
if httpSettings != nil {
if auther := httpSettings.Auther; auther != nil {
username, password, _ := req.BasicAuth()
// Node auth is proxy auth, so it reads the proxy credential
// (Proxy-Authorization) rather than the origin credential
// (Authorization). The two are separate fields precisely so a client
// can authenticate to this node and to the backend independently;
// consuming Authorization here would swallow the credentials the
// client sent for a backend that also uses Basic auth (e.g. a
// reverse-proxied site behind an authenticated node).
username, password, _ := basicProxyAuth(req.Header.Get("Proxy-Authorization"))
id, ok := auther.Authenticate(ctx, username, password, auth.WithService(ho.Service))
if !ok {
res.StatusCode = http.StatusUnauthorized
res.StatusCode = http.StatusProxyAuthRequired
ro.HTTP.StatusCode = res.StatusCode
res.Header.Set("WWW-Authenticate", "Basic")
log.Warnf("node %s(%s) 401 unauthorized", node.Name, node.Addr)
res.Header.Set("Proxy-Authenticate", "Basic")
log.Warnf("node %s(%s) 407 unauthorized", node.Name, node.Addr)
res.Write(rw)
err = errors.New("unauthorized")
return
@@ -526,6 +556,10 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
respHeaderRewrites = httpSettings.RewriteResponseHeader
}
// Proxy-Authorization authenticates the client to this node and is
// hop-by-hop (RFC 9110 §11.7.2): never forward it to the upstream backend.
req.Header.Del("Proxy-Authorization")
// Snapshot the original request body before rewriting, restoring it via
// MultiReader so the body is read from the wire only once. Only when body
// recording is enabled and request bodies are actually rewritten.