Skip to content
Open
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
402 changes: 402 additions & 0 deletions docs/ip-socket-config-design.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions docs/web-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,67 @@ basic_auth_users:
rate_limit:
interval: <duration> # time interval between two requests, set to 0 to disable rate limiter
burst: <int> # and permits a burst of <int> requests.

# IP-layer socket options applied to the listening socket.
# All fields are optional; an omitted field uses the kernel default.
ip_socket_config:
# IPv4 TTL on outbound packets. Valid: 1-255.
# Lower values bound how far response packets can travel; useful as a
# defense-in-depth measure (e.g. ttl=2 means packets die after two
# router hops). On Linux this is inherited by accepted connections
# (accept(2), ip(7)).
[ ipv4_ttl: <int> ]

# IPv6 Hop Limit on outbound packets. Valid: 1-255. Same semantics as
# ipv4_ttl but for IPv6.
[ ipv6_hop_limit: <int> ]

# DSCP codepoint applied to outbound packets via IPv4 ToS and IPv6
# Traffic Class (upper 6 bits). Valid: 0-63. Common values:
# 0 (CS0, best-effort), 8 (CS1), 16 (CS2), 26 (AF31), 46 (EF).
# The 2 ECN bits (lower 2 bits of the ToS byte) are NOT touched --
# the kernel manages them per-packet for ECN-capable TCP (RFC 3168).
[ dscp: <int> ]
```

[A sample configuration file](web-config.yml) is provided.

## About `ip_socket_config`

The `ip_socket_config` block sets IP-layer header fields on the listening
socket. Each option can also be set via a CLI flag or environment variable
(`--web.ipv4-ttl` / `WEB_IPV4_TTL`, `--web.ipv6-hop-limit` /
`WEB_IPV6_HOP_LIMIT`, `--web.dscp` / `WEB_DSCP`); the flag wins when both
the flag and a YAML value are set.

Listener-flavor support:

| Listener | TTL / Hop Limit | DSCP |
|---|---|---|
| Regular TCP | Set on the listening socket via `net.ListenConfig.Control`; inherited by accepted connections. | Set per accepted connection (IP_TOS / IPV6_TCLASS are *not* inherited from the listener on Linux). |
| Systemd socket activation | Set on the systemd-provided listener post-bind via `setsockopt`. | Set per accepted connection (same as regular TCP). |
| VSOCK | Ignored (VSOCK has no IP layer); an info-level log line is emitted if any option is configured. | Same — ignored. |

Platform support:

| Platform | Status |
|---|---|
| Linux | Fully supported, CI-tested. |
| FreeBSD / DragonFly / NetBSD / OpenBSD / Darwin | Compile-supported via `golang.org/x/sys/unix`; not CI-tested. |
| Windows / Plan 9 / JS+Wasm / others | No-op. The first time any IP socket option is configured, a single warn-level log line is emitted and the configured values are ignored. |

Operator notes:

* The minimum useful TTL is **1** (packet dies at the first router; reach is
limited to the local L2 segment). TTL=0 is rejected by configuration
validation — it is forbidden by RFC 1122 §3.2.1.7 and Linux overloads
`setsockopt(IP_TTL, 0)` to mean "use the kernel default" anyway.
* DSCP=0 (CS0) is a valid configured value and is honored; it is *not* the
"not configured" sentinel. Omit the field if you don't want to set DSCP.
* On dual-stack listeners (e.g. `[::]:9100`) both the IPv4 and IPv6 socket
options are set; the kernel applies the appropriate one per outbound
packet.

## About bcrypt

There are several tools out there to generate bcrypt passwords, e.g.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
go.yaml.in/yaml/v2 v2.4.4
golang.org/x/crypto v0.51.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0
golang.org/x/time v0.15.0
)

Expand All @@ -29,7 +30,6 @@ require (
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
158 changes: 62 additions & 96 deletions web/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,81 @@ import (
"time"
)

// TestBasicAuthCache validates that the cache is working by calling a password
// protected endpoint multiple times.
func TestBasicAuthCache(t *testing.T) {
// handlerCase is one row in the TestHandler table. Each case starts an HTTP
// server with the named YAML config and then runs `do` against it. The
// per-case `do` function holds the assertions specific to that case; the
// shared server lifecycle (start, wait, shutdown) is provided by
// withHandlerServer so it doesn't have to be duplicated per case.
type handlerCase struct {
name string
yamlConfigPath string
do func(t *testing.T)
}

func TestHandler(t *testing.T) {
cases := []handlerCase{
{
name: "BasicAuthCache",
yamlConfigPath: "testdata/web_config_users_noTLS.good.yml",
do: testBasicAuthCacheBody,
},
{
name: "BasicAuthWithFakepassword",
yamlConfigPath: "testdata/web_config_users_noTLS.good.yml",
do: testBasicAuthFakepasswordBody,
},
{
name: "ByPassBasicAuthVuln",
yamlConfigPath: "testdata/web_config_users_noTLS.good.yml",
do: testByPassBasicAuthVulnBody,
},
{
name: "HTTPHeaders",
yamlConfigPath: "testdata/web_config_headers.good.yml",
do: testHTTPHeadersBody,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withHandlerServer(t, tc.yamlConfigPath, tc.do)
})
}
}

// withHandlerServer starts an http.Server on the package-level `port` using
// the given YAML config, waits until the port is reachable, runs body, and
// then shuts the server down. Replaces the per-test boilerplate that the
// original four separate test functions duplicated.
func withHandlerServer(t *testing.T, yamlConfigPath string, body func(t *testing.T)) {
t.Helper()
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
WebConfigFile: OfString(yamlConfigPath),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)
body(t)
}

// testBasicAuthCacheBody validates that the cache is working by calling a
// password-protected endpoint repeatedly, then stressing it concurrently.
func testBasicAuthCacheBody(t *testing.T) {
login := func(username, password string, code int) {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
Expand Down Expand Up @@ -89,35 +135,9 @@ func TestBasicAuthCache(t *testing.T) {
wg.Wait()
}

// TestBasicAuthWithFakePassword validates that we can't login the "fakepassword" used in
// to prevent user enumeration.
func TestBasicAuthWithFakepassword(t *testing.T) {
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)

// testBasicAuthFakepasswordBody validates that we can't login with the
// "fakepassword" used to prevent user enumeration.
func testBasicAuthFakepasswordBody(t *testing.T) {
login := func() {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
Expand All @@ -133,41 +153,14 @@ func TestBasicAuthWithFakepassword(t *testing.T) {
t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode)
}
}

// Login with a cold cache.
login()
// Login with the response cached.
login()
}

// TestByPassBasicAuthVuln tests for CVE-2022-46146.
func TestByPassBasicAuthVuln(t *testing.T) {
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)

// testByPassBasicAuthVulnBody tests for CVE-2022-46146.
func testByPassBasicAuthVulnBody(t *testing.T) {
login := func(username, password string) {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
Expand All @@ -183,41 +176,15 @@ func TestByPassBasicAuthVuln(t *testing.T) {
t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode)
}
}

// Poison the cache.
login("alice$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", "fakepassword")
// Login with a wrong password.
login("alice", "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSifakepassword")
}

// TestHTTPHeaders validates that HTTP headers are added correctly.
func TestHTTPHeaders(t *testing.T) {
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_headers.good.yml"),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)

// testHTTPHeadersBody validates that HTTP headers from web_config_headers.good.yml
// are added correctly to responses.
func testHTTPHeadersBody(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
if err != nil {
Expand All @@ -227,7 +194,6 @@ func TestHTTPHeaders(t *testing.T) {
if err != nil {
t.Fatal(err)
}

for k, v := range map[string]string{
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Frame-Options": "deny",
Expand Down
12 changes: 12 additions & 0 deletions web/kingpinflag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ func AddFlags(a flagGroup, defaultAddress string) *web.FlagConfig {
"web.config.file",
"Path to configuration file that can enable TLS or authentication. See: https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md",
).Default("").String(),
WebIPv4TTL: a.Flag(
"web.ipv4-ttl",
"IPv4 TTL to set on the listening socket. Valid: 1-255. 0 (default) leaves the kernel default. Lower values bound how far response packets can travel.",
).Default("0").Envar("WEB_IPV4_TTL").Uint8(),
WebIPv6HopLimit: a.Flag(
"web.ipv6-hop-limit",
"IPv6 Hop Limit to set on the listening socket. Valid: 1-255. 0 (default) leaves the kernel default.",
).Default("0").Envar("WEB_IPV6_HOP_LIMIT").Uint8(),
WebDSCP: a.Flag(
"web.dscp",
"DSCP codepoint applied to outbound packets via IPv4 ToS and IPv6 Traffic Class (upper 6 bits). Valid: 0-63. -1 (default) leaves the kernel default. ECN bits are left for the kernel.",
).Default("-1").Envar("WEB_DSCP").Int(),
}
return &flags
}
Loading