Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions cmd/autocar/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func runServer(parent context.Context, args []string) error {
disableFallback := fs.Bool("disable-tcp-fallback", false, "disable the TCP/TLS fallback listener")
coverRoot := fs.String("cover-root", "", "web protocol: directory served as the public cover origin")
coverUpstream := fs.String("cover-upstream", "", "web protocol: fixed http(s) origin used as the public cover")
coverPublicOrigin := fs.String("cover-public-origin", "", "web upstream only: fixed public HTTPS origin for same-origin website sessions (opt-in)")
certFile := fs.String("cert", "", "server certificate PEM (required)")
keyFile := fs.String("key", "", "server private key PEM (required)")
clientCAFile := fs.String("client-ca", "", "optional PEM CA that enables mandatory mTLS")
Expand Down Expand Up @@ -64,6 +65,19 @@ func runServer(parent context.Context, args []string) error {
if err := validateServerProtocolOptions(serverProtocol, *clientCAFile, *coverRoot, *coverUpstream, *disableFallback); err != nil {
return err
}
var coverHandler http.Handler
if *coverPublicOrigin != "" {
if serverProtocol != "web" || strings.TrimSpace(*coverRoot) != "" || strings.TrimSpace(*coverUpstream) == "" {
return errors.New("--cover-public-origin requires --protocol=web and --cover-upstream, without --cover-root")
}
// This constructor validates only local URL configuration; it performs
// no DNS, upstream requests or listening, including during --check.
var err error
coverHandler, err = buildCoverHandlerWithPublicOrigin(*coverRoot, *coverUpstream, *coverPublicOrigin)
if err != nil {
return err
}
}
if *certFile == "" || *keyFile == "" {
return errors.New("--cert and --key are required")
}
Expand Down Expand Up @@ -160,8 +174,7 @@ func runServer(parent context.Context, args []string) error {
if err != nil {
return err
}
var coverHandler http.Handler
if serverProtocol == "web" {
if serverProtocol == "web" && coverHandler == nil {
coverHandler, err = buildCoverHandler(*coverRoot, *coverUpstream)
if err != nil {
return err
Expand Down Expand Up @@ -337,7 +350,14 @@ func validateServerProtocolOptions(protocolMode, clientCAFile, coverRoot, coverU
}

func buildCoverHandler(root, upstream string) (http.Handler, error) {
return buildCoverHandlerWithPublicOrigin(root, upstream, "")
}

func buildCoverHandlerWithPublicOrigin(root, upstream, publicOrigin string) (http.Handler, error) {
if strings.TrimSpace(root) != "" {
if publicOrigin != "" {
return nil, errors.New("--cover-public-origin requires --cover-upstream, without --cover-root")
}
handler, err := cover.NewStaticHandler(root)
if err != nil {
return nil, fmt.Errorf("configure static cover: %w", err)
Expand All @@ -348,7 +368,16 @@ func buildCoverHandler(root, upstream string) (http.Handler, error) {
if err != nil {
return nil, fmt.Errorf("parse --cover-upstream: %w", err)
}
handler, err := cover.NewReverseProxyHandler(origin, nil)
var handler http.Handler
if publicOrigin == "" {
handler, err = cover.NewReverseProxyHandler(origin, nil)
} else {
public, parseErr := url.Parse(strings.TrimSpace(publicOrigin))
if parseErr != nil {
return nil, errors.New("parse --cover-public-origin: invalid URL")
}
handler, err = cover.NewReverseProxyHandlerWithPublicOrigin(origin, public, nil)
}
if err != nil {
return nil, fmt.Errorf("configure upstream cover: %w", err)
}
Expand Down
85 changes: 85 additions & 0 deletions cmd/autocar/web_public_origin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)

func TestRunServerPublicOriginValidationBeforeCredentials(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{"native", []string{"--cover-public-origin", "https://site.test"}, "requires --protocol=web"},
{"static", []string{"--protocol", "web", "--cover-root", "/unused", "--cover-public-origin", "https://site.test"}, "requires --protocol=web"},
{"http public", []string{"--protocol", "web", "--cover-upstream", "http://origin.test", "--cover-public-origin", "http://site.test"}, "configure upstream cover"},
{"whitespace public", []string{"--protocol", "web", "--cover-upstream", "http://origin.test", "--cover-public-origin", " "}, "configure upstream cover"},
{"bad public URL", []string{"--protocol", "web", "--cover-upstream", "http://origin.test", "--cover-public-origin", "https://site.test:%"}, "parse --cover-public-origin"},
{"upstream base path", []string{"--protocol", "web", "--cover-upstream", "http://origin.test/base", "--cover-public-origin", "https://site.test"}, "configure upstream cover"},
} {
t.Run(test.name, func(t *testing.T) {
err := runServer(context.Background(), test.args)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v; want %q", err, test.want)
}
})
}
}

func TestPublicOriginPreflightStaysOffline(t *testing.T) {
clearPreflightEnvironment(t)
files := newPreflightFiles(t)
dns := denyPreflightDNS(t)
var requests atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) }))
defer upstream.Close()
for _, target := range []string{"https://origin.invalid", upstream.URL} {
args := append(files.serverArgs(), "--protocol", "web", "--cover-upstream", target, "--cover-public-origin", "https://site.invalid:8443/")
if err := runServer(context.Background(), args); err != nil {
t.Fatal(err)
}
}
if dns.Load() != 0 || requests.Load() != 0 {
t.Fatalf("preflight DNS=%d upstream=%d", dns.Load(), requests.Load())
}
}

func TestPublicOriginJSONAndCLIOverride(t *testing.T) {
clearPreflightEnvironment(t)
files := newPreflightFiles(t)
path := writeTestCommandConfig(t, `{"protocol":"web","cover-upstream":"https://origin.invalid","cover-public-origin":"https://site.invalid"}`)
args := append(files.serverArgs(), "--config", path)
if err := runServer(context.Background(), args); err != nil {
t.Fatalf("JSON public origin: %v", err)
}
// Explicit empty CLI opt-out restores the old upstream base-path policy.
args = append(args, "--cover-public-origin=", "--cover-upstream=https://origin.invalid/base?fixed=1")
if err := runServer(context.Background(), args); err != nil {
t.Fatalf("CLI opt-out: %v", err)
}
args = append(args, "--cover-public-origin=http://site.invalid")
if err := runServer(context.Background(), args); err == nil {
t.Fatal("invalid CLI override was accepted")
}
}

func TestPublicOriginCoverFactory(t *testing.T) {
if _, err := buildCoverHandlerWithPublicOrigin(t.TempDir(), "", "https://site.test"); err == nil {
t.Fatal("static public-origin combination accepted")
}
handler, err := buildCoverHandlerWithPublicOrigin("", "https://origin.invalid", "https://site.test")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "https://other.test/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusMisdirectedRequest {
t.Fatalf("wrong Host status=%d", response.Code)
}
}
13 changes: 13 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,19 @@ headers, and preserves the request path and query. Treat that origin as an
Internet-facing application; do not point it at metadata or control-plane
services.

For an owned website that must see its public virtual host, source builds
after v1.0.1 additionally accept `--cover-public-origin https://www.example.com`
(JSON: `"cover-public-origin":"https://www.example.com"`). This optional mode
retains fixed upstream dialing/TLS, uses the public HTTP Host, generates only
trusted public Host/HTTPS forwarding metadata, and rejects mismatched cover
Host or present Origin. Both URLs must be root origins; the public URL must
be HTTPS. Configure the backend's public virtual host, canonical URLs and
CSRF/session policy first: cookies, redirects and Origin are not rewritten.
This is not available with static cover or in the v1.0.1 binary. Offline
`--check` validates the configuration without contacting the upstream.
Remove the key/flag to roll back; existing default behavior is unchanged.
See [public-origin requirements and boundaries](WEB_COVER.md#optional-fixed-public-origin-in-source-builds).

Source builds after v1.0.1 can forward narrowly validated H1.1 WebSocket GET
upgrades to this same fixed origin; no new flag or arbitrary Upgrade proxy is
introduced. Authorization and nominated hop fields are still stripped.
Expand Down
56 changes: 56 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,62 @@ the upstream cannot be reached. Consequently, an upstream that requires an
`Authorization` request header is not suitable without a separate authorized
front end.

#### Optional fixed public origin in source builds

Source builds after v1.0.1 can separate the website's public HTTP identity
from the fixed upstream connection address:

```bash
./autocar server \
--protocol web --listen :443 --tcp-listen :443 \
--cover-upstream https://origin.example.net \
--cover-public-origin https://www.example.com \
--cert /etc/autocar/server.crt --key /etc/autocar/server.key \
--token-file /etc/autocar/relay-token
```

This is opt-in, upstream-only, and not part of the v1.0.1 binary. Without
`--cover-public-origin`, the existing upstream-Host and base-path/query behavior
is unchanged. With it, both configured URLs must be root origins (empty path
or `/`, no query, user information or fragment); the public origin must use
HTTPS. Use canonical ASCII DNS names (including already encoded punycode),
IPv4, or bracketed IPv6, with an optional numeric port from 1 to 65535.
Trailing dots, zone IDs, nonstandard numeric IP aliases and leading-zero ports
are rejected; no DNS or IDNA conversion is performed during validation.
DNS case and explicit default ports are equivalent for the public guard.

The upstream URL still controls dialing, TLS certificate verification and
SNI. Only the outgoing HTTP `Host` changes to the configured public authority.
Incoming `Forwarded`, all `X-Forwarded-*` headers and `X-Real-IP` are removed
from headers and declared request trailers;
the proxy generates only fixed `X-Forwarded-Host` and `X-Forwarded-Proto: https`.
It does not forward the visitor's IP address. Configure the backend to accept
this public virtual host, generate public canonical URLs, and trust **only**
these two relay-generated headers from this relay. Other backend-specific
trust headers are not a universal allowlist: do not trust arbitrary client
headers or expose an internal control plane as the website.

Cover requests with a different Host receive ordinary `421 Misdirected
Request`. If an Origin header is present it must contain exactly one valid
same-public HTTPS origin; foreign, null, empty or ambiguous values receive
ordinary `403 Forbidden`. A Connection nomination of Origin or Referer also
receives `403`, before hop-header stripping can hide it. This opt-in mode is
for same-origin websites, not cross-origin CORS applications. Authenticated
tunnel dispatch is unchanged: these guards apply only to the website handler.

Cookie, Set-Cookie, Location, Origin, Referer and HTML are **not rewritten**.
The website remains responsible for CSRF tokens, session authentication,
cookie domains/attributes and its own Origin policy, including requests with
no Origin. Origin guards inspect HTTP headers, not trailer values; the backend
must not merge security or trust trailers into request headers. A backend that
compares Origin and Host lexically can still reject
equivalent noncanonical spellings; the proxy deliberately preserves Origin
bytes. Third-party redirects are passed through, not followed by the proxy.
This mode cannot make an arbitrary third-party site compatible or establish
browser-like traffic fingerprints. `--check` validates it offline; JSON uses
the same `cover-public-origin` key. Remove the flag/key (or explicitly pass
`--cover-public-origin=`) to restore the default mode.

For an actual `OPTIONS *` request, source builds after v1.0.1 preserve the
asterisk request-target at the fixed upstream authority. The configured base
path and query are not added: this request concerns the origin as a whole, not
Expand Down
19 changes: 18 additions & 1 deletion internal/cover/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
if err != nil {
return nil, err
}
return newReverseProxyHandler(target, transport, ""), nil
}

func newReverseProxyHandler(target *url.URL, transport http.RoundTripper, publicAuthority string) *httputil.ReverseProxy {
if transport == nil {
defaultTransport := http.DefaultTransport.(*http.Transport).Clone()
defaultTransport.Proxy = nil
Expand All @@ -62,6 +66,19 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
// nomination boundary, and restore only our validated H1 WebSocket.
removeConnectionNominatedHeaders(request.Out.Header, request.In.Header)
removeUnsafeHeaders(request.Out.Header)
if publicAuthority != "" {
// The URL still chooses the fixed upstream dial and TLS target.
// Only this opt-in mode supplies a configured public vhost and
// trusted forwarding metadata, after all request nominations.
removePublicOriginForwardingHeaders(request.Out.Header)
// Replayed bodies can already have populated Trailer values.
// ReverseProxy owns this cloned map; do not trust those fields
// or mutate the original request's body or trailer map.
removePublicOriginForwardingHeaders(request.Out.Trailer)
request.Out.Host = publicAuthority
request.Out.Header.Set("X-Forwarded-Host", publicAuthority)
request.Out.Header.Set("X-Forwarded-Proto", "https")
}
if upgrade.eligible {
request.Out.Header.Set("Connection", "Upgrade")
request.Out.Header.Set("Upgrade", "websocket")
Expand Down Expand Up @@ -99,7 +116,7 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
},
ErrorLog: log.New(io.Discard, "", 0),
}
return proxy, nil
return proxy
}

// informationalHeaderTransport filters upstream headers before ReverseProxy's
Expand Down
Loading
Loading