Add the HTTP hardening the policy requires before 1.0 (closes #108)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Every answer carries the six security headers REPO_POLICIES.md lists. The Content-Security-Policy allows inline content only with a nonce made for that answer; the pages put it on their style and script blocks and drop their four style attributes. The request log and /ip/ take a forwarded address only from a proxy listed in TRUSTED_PROXIES, which stops the start when set but invalid: the last X-Forwarded-For entry that is not a trusted proxy. X-Real-IP and True-Client-IP are no longer read. The JSON routes and the health check send fixed messages in place of database, parser and resolver errors, and log the detail. Model: opus-5-5
This commit is contained in:
@@ -54,8 +54,9 @@ DEBUG=routewatch ./routewatch
|
|||||||
picked at random, each with its newest route (HTML)
|
picked at random, each with its newest route (HTML)
|
||||||
- `GET /prefixlength6/{length}` - The same for IPv6
|
- `GET /prefixlength6/{length}` - The same for IPv6
|
||||||
- `GET /ip/{addr}` - The same JSON answer as `/api/v1/ip/{ip}`
|
- `GET /ip/{addr}` - The same JSON answer as `/api/v1/ip/{ip}`
|
||||||
- `GET /ip/` - The same, for the caller's own address, or the one a proxy names
|
- `GET /ip/` - The same, for the caller's own address: the address the request
|
||||||
in `True-Client-IP`, `X-Real-IP` or `X-Forwarded-For`
|
came from or, when that is a trusted proxy (`TRUSTED_PROXIES`, see
|
||||||
|
Configuration), the client address in its `X-Forwarded-For`
|
||||||
|
|
||||||
### API v1
|
### API v1
|
||||||
|
|
||||||
@@ -84,7 +85,17 @@ and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
|
|||||||
An AS number, prefix or mask length that does not parse answers 400, as does a
|
An AS number, prefix or mask length that does not parse answers 400, as does a
|
||||||
mask length out of range for its IP version. An AS the database does not hold,
|
mask length out of range for its IP version. An AS the database does not hold,
|
||||||
or a prefix with no live route, answers 404. The HTML pages give these errors as
|
or a prefix with no live route, answers 404. The HTML pages give these errors as
|
||||||
plain text.
|
plain text. A 500 answer says only `Internal server error`, and a host name that
|
||||||
|
does not resolve only `could not resolve host name`; the detail goes to the log.
|
||||||
|
|
||||||
|
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
|
||||||
|
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
|
||||||
|
`Permissions-Policy`. The Content-Security-Policy lets a page load content only
|
||||||
|
from the server, and does not allow inline content with `'unsafe-inline'`: the
|
||||||
|
pages keep their CSS and JavaScript in inline `<style>` and `<script>` blocks,
|
||||||
|
so each answer's policy names a random nonce made for that answer, and the
|
||||||
|
browser applies or runs only the blocks that carry it. The pages therefore use
|
||||||
|
no `style` attributes, which a nonce cannot cover.
|
||||||
|
|
||||||
An origin AS of 0 (`origin_asn` in a route, `asn` in an IP lookup, the key `0`
|
An origin AS of 0 (`origin_asn` in a route, `asn` in an IP lookup, the key `0`
|
||||||
in a prefix's `origins`) means the route has no known origin: its AS path ends
|
in a prefix's `origins`) means the route has no known origin: its AS path ends
|
||||||
@@ -247,10 +258,20 @@ Configuration is handled via environment variables and OS-specific paths:
|
|||||||
| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path |
|
| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path |
|
||||||
| `GOMEMLIMIT` | `1536MiB` (in the Docker image) | Go soft memory limit; see Memory |
|
| `GOMEMLIMIT` | `1536MiB` (in the Docker image) | Go soft memory limit; see Memory |
|
||||||
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory |
|
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory |
|
||||||
|
| `TRUSTED_PROXIES` | (empty) | Addresses and CIDR ranges of trusted proxies; see below |
|
||||||
|
|
||||||
A variable that is set to an invalid value stops the start with an error and a
|
A variable that is set to an invalid value stops the start with an error and a
|
||||||
non-zero exit. An empty variable counts as unset.
|
non-zero exit. An empty variable counts as unset.
|
||||||
|
|
||||||
|
`TRUSTED_PROXIES` lists the reverse proxies whose `X-Forwarded-For` the server
|
||||||
|
believes, comma-separated, such as `10.0.0.0/8,192.0.2.1`. For a request from
|
||||||
|
one of them, the request log and `/ip/` use the last address in
|
||||||
|
`X-Forwarded-For` that is not a trusted proxy: each proxy adds the address it
|
||||||
|
received the request from to the end, so the entries before it are whatever the
|
||||||
|
client sent. For a request from any other address they use the address the
|
||||||
|
request came from, whatever forwarding headers it carries. `X-Real-IP` and
|
||||||
|
`True-Client-IP` are not read.
|
||||||
|
|
||||||
State directory (database location):
|
State directory (database location):
|
||||||
|
|
||||||
- macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/`
|
- macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/`
|
||||||
@@ -336,9 +357,12 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
|
|||||||
|
|
||||||
- Container port: `8080`.
|
- Container port: `8080`.
|
||||||
- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`.
|
- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`.
|
||||||
- Environment: nothing is required. Leave `XDG_DATA_HOME`, `GOMEMLIMIT` and
|
- Environment: `TRUSTED_PROXIES`, set to the address the reverse proxy in front
|
||||||
`MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch` is optional and
|
of the app connects from, as the container sees it; without it the request log
|
||||||
adds the `System stats` memory line to the log.
|
and `/ip/` give the proxy's address for every request. Nothing else is needed:
|
||||||
|
leave `XDG_DATA_HOME`, `GOMEMLIMIT` and `MALLOC_ARENA_MAX` at the image's
|
||||||
|
values. `DEBUG=routewatch` is optional and adds the `System stats` memory line
|
||||||
|
to the log.
|
||||||
- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap
|
- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap
|
||||||
limit, so on a host with swap Docker allows the same amount of swap again.
|
limit, so on a host with swap Docker allows the same amount of swap again.
|
||||||
- Health check: the image's `HEALTHCHECK` requests
|
- Health check: the image's `HEALTHCHECK` requests
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ var (
|
|||||||
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
|
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
|
||||||
errUnsupportedOS = errors.New("unsupported operating system")
|
errUnsupportedOS = errors.New("unsupported operating system")
|
||||||
errInvalidPort = errors.New("PORT must be a whole number")
|
errInvalidPort = errors.New("PORT must be a whole number")
|
||||||
|
errInvalidProxy = errors.New(
|
||||||
|
"TRUSTED_PROXIES must list IP addresses and CIDR ranges, comma-separated")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds configuration for the entire application
|
// Config holds configuration for the entire application
|
||||||
@@ -42,6 +46,10 @@ type Config struct {
|
|||||||
// Port is the TCP port the HTTP server listens on
|
// Port is the TCP port the HTTP server listens on
|
||||||
Port int
|
Port int
|
||||||
|
|
||||||
|
// TrustedProxies are the reverse proxies whose X-Forwarded-For header
|
||||||
|
// the HTTP server believes
|
||||||
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// MaxRuntime is the maximum runtime (0 = run forever)
|
// MaxRuntime is the maximum runtime (0 = run forever)
|
||||||
MaxRuntime time.Duration
|
MaxRuntime time.Duration
|
||||||
|
|
||||||
@@ -68,9 +76,15 @@ func New() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trustedProxies, err := getTrustedProxies()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
StateDir: stateDir,
|
StateDir: stateDir,
|
||||||
Port: port,
|
Port: port,
|
||||||
|
TrustedProxies: trustedProxies,
|
||||||
// Run forever by default
|
// Run forever by default
|
||||||
MaxRuntime: 0,
|
MaxRuntime: 0,
|
||||||
// Enable batching by default
|
// Enable batching by default
|
||||||
@@ -145,6 +159,38 @@ func getPort() (int, error) {
|
|||||||
return int(port), nil
|
return int(port), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTrustedProxies returns the addresses and CIDR ranges listed in
|
||||||
|
// TRUSTED_PROXIES, such as "10.0.0.0/8,192.0.2.1", or none when it is not set.
|
||||||
|
// An address is returned as a range holding only that address.
|
||||||
|
func getTrustedProxies() ([]netip.Prefix, error) {
|
||||||
|
value := os.Getenv("TRUSTED_PROXIES")
|
||||||
|
if value == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxies []netip.Prefix
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(value, ",") {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
|
||||||
|
addr, err := netip.ParseAddr(entry)
|
||||||
|
if err == nil {
|
||||||
|
proxies = append(proxies, netip.PrefixFrom(addr, addr.BitLen()))
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, err := netip.ParsePrefix(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w, got %q", errInvalidProxy, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies = append(proxies, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxies, nil
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureDirectories creates all necessary directories if they don't exist
|
// EnsureDirectories creates all necessary directories if they don't exist
|
||||||
func (c *Config) EnsureDirectories() error {
|
func (c *Config) EnsureDirectories() error {
|
||||||
// Ensure state directory exists
|
// Ensure state directory exists
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +46,55 @@ func TestNewRefusesInvalidPort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewReadsTrustedProxies(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "")
|
||||||
|
t.Setenv("XDG_DATA_HOME", "")
|
||||||
|
|
||||||
|
t.Setenv("TRUSTED_PROXIES", "")
|
||||||
|
|
||||||
|
cfg, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() without TRUSTED_PROXIES: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.TrustedProxies) != 0 {
|
||||||
|
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want none",
|
||||||
|
cfg.TrustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8, 192.0.2.1,2001:db8::/32")
|
||||||
|
|
||||||
|
cfg, err = New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() with TRUSTED_PROXIES: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("192.0.2.1/32"),
|
||||||
|
netip.MustParsePrefix("2001:db8::/32"),
|
||||||
|
}
|
||||||
|
if !slices.Equal(cfg.TrustedProxies, want) {
|
||||||
|
t.Errorf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRefusesInvalidTrustedProxies(t *testing.T) {
|
||||||
|
invalid := []string{"proxy.example", "10.0.0.0/33", "192.0.2.1:80", "192.0.2.1,"}
|
||||||
|
for _, value := range invalid {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "")
|
||||||
|
t.Setenv("XDG_DATA_HOME", "")
|
||||||
|
t.Setenv("TRUSTED_PROXIES", value)
|
||||||
|
|
||||||
|
_, err := New()
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("New() with TRUSTED_PROXIES=%q returned no error", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRefusesRelativeXDGDataHome(t *testing.T) {
|
func TestNewRefusesRelativeXDGDataHome(t *testing.T) {
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
t.Skip("macOS does not read XDG_DATA_HOME")
|
t.Skip("macOS does not read XDG_DATA_HOME")
|
||||||
|
|||||||
+54
-20
@@ -41,6 +41,12 @@ const (
|
|||||||
jsonStatusError = "error"
|
jsonStatusError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Error messages an answer sends in place of the error that caused it.
|
||||||
|
const (
|
||||||
|
invalidPrefixMessage = "Invalid prefix"
|
||||||
|
hostNotResolvedMessage = "could not resolve host name"
|
||||||
|
)
|
||||||
|
|
||||||
// IP versions as database.LiveRoute.IPVersion records them.
|
// IP versions as database.LiveRoute.IPVersion records them.
|
||||||
const (
|
const (
|
||||||
ipVersionV4 = 4
|
ipVersionV4 = 4
|
||||||
@@ -68,7 +74,8 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
|
|||||||
// Check database connectivity with lightweight ping
|
// Check database connectivity with lightweight ping
|
||||||
err := s.db.Ping(ctx)
|
err := s.db.Ping(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
checks["database"] = "error: " + err.Error()
|
s.logger.Error("Health check database ping failed", "error", err)
|
||||||
|
checks["database"] = jsonStatusError
|
||||||
healthy = false
|
healthy = false
|
||||||
} else {
|
} else {
|
||||||
checks["database"] = "ok"
|
checks["database"] = "ok"
|
||||||
@@ -108,12 +115,12 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
|
|||||||
|
|
||||||
// handleIndex returns a handler that serves the home page.
|
// handleIndex returns a handler that serves the home page.
|
||||||
func (s *Server) handleIndex() http.HandlerFunc {
|
func (s *Server) handleIndex() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
tmpl := templates.IndexTemplate()
|
tmpl := templates.IndexTemplate()
|
||||||
|
|
||||||
err := tmpl.Execute(w, nil)
|
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to render index template", "error", err)
|
s.logger.Error("Failed to render index template", "error", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
@@ -121,6 +128,12 @@ func (s *Server) handleIndex() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageData is what the pages that show no data are rendered with: the nonce
|
||||||
|
// their script and style blocks carry.
|
||||||
|
type pageData struct {
|
||||||
|
CSPNonce string
|
||||||
|
}
|
||||||
|
|
||||||
// writeJSONError writes a standardized JSON error response with the given
|
// writeJSONError writes a standardized JSON error response with the given
|
||||||
// status code and error message.
|
// status code and error message.
|
||||||
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
|
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
|
||||||
@@ -251,7 +264,7 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get database stats", "error", err)
|
s.logger.Error("Failed to get database stats", "error", err)
|
||||||
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -541,7 +554,7 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get database stats", "error", err)
|
s.logger.Error("Failed to get database stats", "error", err)
|
||||||
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -622,12 +635,12 @@ func (s *Server) statsResponse(
|
|||||||
// handleStatusHTML returns a handler that serves the HTML status page,
|
// handleStatusHTML returns a handler that serves the HTML status page,
|
||||||
// which displays real-time statistics fetched via JavaScript.
|
// which displays real-time statistics fetched via JavaScript.
|
||||||
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
tmpl := templates.StatusTemplate()
|
tmpl := templates.StatusTemplate()
|
||||||
|
|
||||||
err := tmpl.Execute(w, nil)
|
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to render template", "error", err)
|
s.logger.Error("Failed to render template", "error", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
@@ -667,7 +680,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Collect all IPs to look up
|
// Collect all IPs to look up
|
||||||
ipsToLookup, resolveErrors := resolveLookupTargets(ctx, target)
|
ipsToLookup, resolveErrors := s.resolveLookupTargets(ctx, target)
|
||||||
if deadlinePassed(ctx) {
|
if deadlinePassed(ctx) {
|
||||||
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
||||||
|
|
||||||
@@ -695,7 +708,14 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Errors = append(response.Errors, ip+": "+err.Error())
|
message := err.Error()
|
||||||
|
if !errors.Is(err, database.ErrNoRoute) &&
|
||||||
|
!errors.Is(err, database.ErrInvalidIP) {
|
||||||
|
s.logger.Error("Failed to look up IP", "ip", ip, "error", err)
|
||||||
|
message = internalErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Errors = append(response.Errors, ip+": "+message)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -766,9 +786,12 @@ func (s *Server) queueWHOISRefresh(asns map[int]bool) {
|
|||||||
|
|
||||||
// resolveLookupTargets splits a comma-separated list of IP addresses and
|
// resolveLookupTargets splits a comma-separated list of IP addresses and
|
||||||
// hostnames and returns the IP addresses to look up, resolving each hostname.
|
// hostnames and returns the IP addresses to look up, resolving each hostname.
|
||||||
// A hostname that does not resolve is reported in the returned errors instead.
|
// A hostname that does not resolve is reported in the returned errors instead,
|
||||||
|
// with the resolver's own error only in the log, since it names the DNS server.
|
||||||
// It stops once ctx's deadline has passed; the caller then answers 504.
|
// It stops once ctx's deadline has passed; the caller then answers 504.
|
||||||
func resolveLookupTargets(ctx context.Context, target string) ([]string, []string) {
|
func (s *Server) resolveLookupTargets(
|
||||||
|
ctx context.Context, target string,
|
||||||
|
) ([]string, []string) {
|
||||||
var ipsToLookup, resolveErrors []string
|
var ipsToLookup, resolveErrors []string
|
||||||
|
|
||||||
for t := range strings.SplitSeq(target, ",") {
|
for t := range strings.SplitSeq(target, ",") {
|
||||||
@@ -791,7 +814,8 @@ func resolveLookupTargets(ctx context.Context, target string) ([]string, []strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resolveErrors = append(resolveErrors, t+": "+err.Error())
|
s.logger.Warn("Host name lookup failed", "host", t, "error", err)
|
||||||
|
resolveErrors = append(resolveErrors, t+": "+hostNotResolvedMessage)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -819,7 +843,7 @@ func lookupPTRNames(ctx context.Context, ip string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// extractClientIP extracts the client IP from the request.
|
// extractClientIP extracts the client IP from the request.
|
||||||
// Works with chi's RealIP middleware which sets RemoteAddr.
|
// Works with RealIPMiddleware, which sets RemoteAddr.
|
||||||
func extractClientIP(r *http.Request) string {
|
func extractClientIP(r *http.Request) string {
|
||||||
// RemoteAddr is in the form "IP:port" or just "IP" for unix sockets
|
// RemoteAddr is in the form "IP:port" or just "IP" for unix sockets
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
@@ -854,7 +878,9 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
|||||||
if errors.Is(err, database.ErrNoRoute) {
|
if errors.Is(err, database.ErrNoRoute) {
|
||||||
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
||||||
} else {
|
} else {
|
||||||
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
|
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
|
||||||
|
s.writeJSONError(w, http.StatusInternalServerError,
|
||||||
|
internalErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -933,7 +959,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
|||||||
// tables store, so that any valid way of writing it finds the route.
|
// tables store, so that any valid way of writing it finds the route.
|
||||||
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeJSONError(w, http.StatusBadRequest, err.Error())
|
s.writeJSONError(w, http.StatusBadRequest, invalidPrefixMessage)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -951,7 +977,10 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
|||||||
if errors.Is(err, database.ErrNoRoute) {
|
if errors.Is(err, database.ErrNoRoute) {
|
||||||
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
||||||
} else {
|
} else {
|
||||||
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
|
s.logger.Error("Failed to get prefix details",
|
||||||
|
"prefix", prefix, "error", err)
|
||||||
|
s.writeJSONError(w, http.StatusInternalServerError,
|
||||||
|
internalErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1002,7 +1031,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
|||||||
http.Error(w, "AS not found", http.StatusNotFound)
|
http.Error(w, "AS not found", http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
s.logger.Error("Failed to get AS details", "error", err)
|
s.logger.Error("Failed to get AS details", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1039,6 +1068,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
|||||||
IPv6Count int
|
IPv6Count int
|
||||||
Peers []database.ASPeer
|
Peers []database.ASPeer
|
||||||
PeerCount int
|
PeerCount int
|
||||||
|
CSPNonce string
|
||||||
}{
|
}{
|
||||||
ASN: asInfo,
|
ASN: asInfo,
|
||||||
IPv4Prefixes: ipv4Prefixes,
|
IPv4Prefixes: ipv4Prefixes,
|
||||||
@@ -1048,6 +1078,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
|||||||
IPv6Count: len(ipv6Prefixes),
|
IPv6Count: len(ipv6Prefixes),
|
||||||
Peers: peers,
|
Peers: peers,
|
||||||
PeerCount: len(peers),
|
PeerCount: len(peers),
|
||||||
|
CSPNonce: cspNonce(r.Context()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if context is still valid before writing response
|
// Check if context is still valid before writing response
|
||||||
@@ -1085,7 +1116,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
|||||||
// tables store, so that any valid way of writing it finds the route.
|
// tables store, so that any valid way of writing it finds the route.
|
||||||
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid prefix", http.StatusBadRequest)
|
http.Error(w, invalidPrefixMessage, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1135,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
|||||||
http.Error(w, "Prefix not found", http.StatusNotFound)
|
http.Error(w, "Prefix not found", http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
s.logger.Error("Failed to get prefix details", "error", err)
|
s.logger.Error("Failed to get prefix details", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1131,6 +1162,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
|||||||
Origins []*ASNInfo
|
Origins []*ASNInfo
|
||||||
PeerCount int
|
PeerCount int
|
||||||
OriginCount int
|
OriginCount int
|
||||||
|
CSPNonce string
|
||||||
}{
|
}{
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
MaskLength: maskLength,
|
MaskLength: maskLength,
|
||||||
@@ -1139,6 +1171,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
|||||||
Origins: origins,
|
Origins: origins,
|
||||||
PeerCount: len(routes),
|
PeerCount: len(routes),
|
||||||
OriginCount: len(origins),
|
OriginCount: len(origins),
|
||||||
|
CSPNonce: cspNonce(r.Context()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if context is still valid before writing response
|
// Check if context is still valid before writing response
|
||||||
@@ -1303,7 +1336,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get prefixes by length", "error", err)
|
s.logger.Error("Failed to get prefixes by length", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1324,6 +1357,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
|
|||||||
"IPVersion": ipVersion,
|
"IPVersion": ipVersion,
|
||||||
"Prefixes": prefixesWithASDescriptions(prefixes),
|
"Prefixes": prefixesWithASDescriptions(prefixes),
|
||||||
"Count": len(prefixes),
|
"Count": len(prefixes),
|
||||||
|
"CSPNonce": cspNonce(r.Context()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if context is still valid before writing response
|
// Check if context is still valid before writing response
|
||||||
|
|||||||
@@ -758,6 +758,85 @@ func checkJSONErrorCode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errDiskIO is the database driver's error failingDB answers with.
|
||||||
|
var errDiskIO = errors.New("disk I/O error")
|
||||||
|
|
||||||
|
// failingDB embeds database.Store (left nil) and fails the reads of the JSON
|
||||||
|
// routes and the health check's ping with errDiskIO. Every other Store method
|
||||||
|
// is unused and would panic if called.
|
||||||
|
type failingDB struct {
|
||||||
|
database.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingDB) Ping(_ context.Context) error {
|
||||||
|
return errDiskIO
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingDB) GetStatsContext(_ context.Context) (database.Stats, error) {
|
||||||
|
return database.Stats{}, errDiskIO
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingDB) GetIPInfoContext(
|
||||||
|
_ context.Context, _ string,
|
||||||
|
) (*database.IPInfo, error) {
|
||||||
|
return nil, errDiskIO
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingDB) GetASDetailsContext(
|
||||||
|
_ context.Context, _ int,
|
||||||
|
) (*database.ASN, []database.LiveRoute, error) {
|
||||||
|
return nil, nil, errDiskIO
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingDB) GetPrefixDetailsContext(
|
||||||
|
_ context.Context, _ string,
|
||||||
|
) ([]database.LiveRoute, map[int]database.ASN, error) {
|
||||||
|
return nil, nil, errDiskIO
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route and
|
||||||
|
// the health check with a store whose reads fail with the driver's error text,
|
||||||
|
// and a prefix that does not parse, and checks that no answer carries that
|
||||||
|
// text. Before the fix the answers sent it to the client
|
||||||
|
// (https://git.eeqj.de/sneak/routewatch/issues/108). The IP lookup answers 404,
|
||||||
|
// as it does whenever no address could be looked up.
|
||||||
|
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := New(failingDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
|
||||||
|
&config.Config{})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
wantCode int
|
||||||
|
detail string // the error text the answer must not carry
|
||||||
|
}{
|
||||||
|
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable, "disk I/O"},
|
||||||
|
{"/status.json", http.StatusInternalServerError, "disk I/O"},
|
||||||
|
{"/api/v1/stats", http.StatusInternalServerError, "disk I/O"},
|
||||||
|
{"/api/v1/as/64500", http.StatusInternalServerError, "disk I/O"},
|
||||||
|
{"/api/v1/prefix/192.0.2.0/24", http.StatusInternalServerError, "disk I/O"},
|
||||||
|
{"/api/v1/ip/192.0.2.1", http.StatusNotFound, "disk I/O"},
|
||||||
|
{"/api/v1/prefix/foo/24", http.StatusBadRequest, "netip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, tt.path, nil))
|
||||||
|
|
||||||
|
if rec.Code != tt.wantCode {
|
||||||
|
t.Errorf("%s: status %d, want %d; body %s",
|
||||||
|
tt.path, rec.Code, tt.wantCode, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), tt.detail) {
|
||||||
|
t.Errorf("%s: body %s carries the error text %q",
|
||||||
|
tt.path, rec.Body, tt.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||||
// current count.
|
// current count.
|
||||||
func settledGoroutineCount() int {
|
func settledGoroutineCount() int {
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -118,6 +122,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
|
|||||||
// requestTimeoutMessage is the error message sent with 504 Gateway Timeout.
|
// requestTimeoutMessage is the error message sent with 504 Gateway Timeout.
|
||||||
const requestTimeoutMessage = "Request timeout"
|
const requestTimeoutMessage = "Request timeout"
|
||||||
|
|
||||||
|
// internalErrorMessage is the error message sent with 500 Internal Server
|
||||||
|
// Error, whatever went wrong; the handler logs the detail.
|
||||||
|
const internalErrorMessage = "Internal server error"
|
||||||
|
|
||||||
// deadlinePassed reports whether ctx ended because its deadline passed.
|
// deadlinePassed reports whether ctx ended because its deadline passed.
|
||||||
//
|
//
|
||||||
// A handler calls it right after each database read or host name lookup,
|
// A handler calls it right after each database read or host name lookup,
|
||||||
@@ -194,7 +202,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
errorMsg := "Internal server error"
|
errorMsg := internalErrorMessage
|
||||||
if statusCode == http.StatusGatewayTimeout {
|
if statusCode == http.StatusGatewayTimeout {
|
||||||
errorMsg = requestTimeoutMessage
|
errorMsg = requestTimeoutMessage
|
||||||
}
|
}
|
||||||
@@ -296,3 +304,105 @@ func DecodedPathMiddleware(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The values of the headers SecurityHeadersMiddleware sets.
|
||||||
|
const (
|
||||||
|
// strictTransportSecurity tells browsers to reach this host and its
|
||||||
|
// subdomains only over HTTPS for a year. The server listens on plain
|
||||||
|
// HTTP behind a TLS-terminating proxy, which passes the header on.
|
||||||
|
strictTransportSecurity = "max-age=31536000; includeSubDomains"
|
||||||
|
|
||||||
|
// contentSecurityPolicy lets a page load content only from this server,
|
||||||
|
// and run or apply an inline script or style block only when the block
|
||||||
|
// carries the nonce made for that answer, which replaces %[1]s. No other
|
||||||
|
// site may frame the pages.
|
||||||
|
contentSecurityPolicy = "default-src 'self'; " +
|
||||||
|
"script-src 'nonce-%[1]s'; style-src 'nonce-%[1]s'; " +
|
||||||
|
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
|
||||||
|
frameOptions = "DENY"
|
||||||
|
contentTypeOptions = "nosniff"
|
||||||
|
referrerPolicy = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
// permissionsPolicy turns off browser features the pages do not use.
|
||||||
|
permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), " +
|
||||||
|
"gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cspNonceKey is the request context key of the nonce SecurityHeadersMiddleware
|
||||||
|
// made for the answer.
|
||||||
|
type cspNonceKey struct{}
|
||||||
|
|
||||||
|
// SecurityHeadersMiddleware sets the security headers REPO_POLICIES.md requires
|
||||||
|
// on every answer. Each answer's Content-Security-Policy names a nonce of its
|
||||||
|
// own; a page reads it with cspNonce and writes it on its script and style
|
||||||
|
// blocks.
|
||||||
|
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nonce := rand.Text()
|
||||||
|
|
||||||
|
header := w.Header()
|
||||||
|
header.Set("Strict-Transport-Security", strictTransportSecurity)
|
||||||
|
header.Set("Content-Security-Policy",
|
||||||
|
fmt.Sprintf(contentSecurityPolicy, nonce))
|
||||||
|
header.Set("X-Frame-Options", frameOptions)
|
||||||
|
header.Set("X-Content-Type-Options", contentTypeOptions)
|
||||||
|
header.Set("Referrer-Policy", referrerPolicy)
|
||||||
|
header.Set("Permissions-Policy", permissionsPolicy)
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), cspNonceKey{}, nonce)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cspNonce returns the nonce SecurityHeadersMiddleware made for the answer to
|
||||||
|
// the request with ctx.
|
||||||
|
func cspNonce(ctx context.Context) string {
|
||||||
|
nonce, _ := ctx.Value(cspNonceKey{}).(string)
|
||||||
|
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealIPMiddleware gives a request from one of trustedProxies the address of
|
||||||
|
// the client the proxies forwarded it for as its RemoteAddr: the last
|
||||||
|
// X-Forwarded-For entry that is not a trusted proxy. Each proxy appends the
|
||||||
|
// address it took the request from, so the entries left of that one are
|
||||||
|
// whatever the client sent. A request from any other peer keeps the peer's
|
||||||
|
// address, whatever headers it carries.
|
||||||
|
func RealIPMiddleware(trustedProxies []netip.Prefix) func(http.Handler) http.Handler {
|
||||||
|
isTrusted := func(addr netip.Addr) bool {
|
||||||
|
return slices.ContainsFunc(trustedProxies, func(proxy netip.Prefix) bool {
|
||||||
|
return proxy.Contains(addr.Unmap())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
peer, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||||
|
if err != nil || !isTrusted(peer.Addr()) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A header sent on several lines is one list.
|
||||||
|
entries := strings.Split(
|
||||||
|
strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
|
||||||
|
|
||||||
|
for _, entry := range slices.Backward(entries) {
|
||||||
|
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
r.RemoteAddr = addr.String()
|
||||||
|
|
||||||
|
if !isTrusted(addr) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResponsesCarrySecurityHeaders checks that a page, a JSON route and a
|
||||||
|
// path no route matches each answer with the security headers REPO_POLICIES.md
|
||||||
|
// requires, and that the Content-Security-Policy allows no inline content
|
||||||
|
// without a nonce (https://git.eeqj.de/sneak/routewatch/issues/108).
|
||||||
|
func TestResponsesCarrySecurityHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newServerWithTestRoutes(t)
|
||||||
|
|
||||||
|
headers := []string{
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"X-Frame-Options",
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
"Referrer-Policy",
|
||||||
|
"Permissions-Policy",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/api/v1/stats", "/no-such-page"} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, path, nil))
|
||||||
|
|
||||||
|
for _, name := range headers {
|
||||||
|
if rec.Header().Get(name) == "" {
|
||||||
|
t.Errorf("%s: no %s header", path, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := rec.Header().Get("Content-Security-Policy")
|
||||||
|
if strings.Contains(policy, "'unsafe-") {
|
||||||
|
t.Errorf("%s: Content-Security-Policy %q allows unsafe content",
|
||||||
|
path, policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagesCarryTheCSPNonce checks that every script and style block of each
|
||||||
|
// page carries the nonce its Content-Security-Policy header names, so that the
|
||||||
|
// browser runs and applies them, that each answer has a nonce of its own, and
|
||||||
|
// that no page uses a style attribute, which the policy does not allow.
|
||||||
|
func TestPagesCarryTheCSPNonce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newServerWithTestRoutes(t)
|
||||||
|
|
||||||
|
_, err := s.db.GetOrCreateASN(64500, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateASN: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
"/", "/status", "/as/64500", "/prefix/" + testPrefixV4,
|
||||||
|
"/prefixlength/24", "/prefixlength6/32",
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, path, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("%s: status %d, want %d; body %s",
|
||||||
|
path, rec.Code, http.StatusOK, rec.Body)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := rec.Header().Get("Content-Security-Policy")
|
||||||
|
|
||||||
|
_, nonce, found := strings.Cut(policy, "'nonce-")
|
||||||
|
if !found {
|
||||||
|
t.Errorf("%s: Content-Security-Policy %q names no nonce", path, policy)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, _, _ = strings.Cut(nonce, "'")
|
||||||
|
if seen[nonce] {
|
||||||
|
t.Errorf("%s: nonce %q was already used for another answer", path, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[nonce] = true
|
||||||
|
page := rec.Body.String()
|
||||||
|
|
||||||
|
for _, tag := range []string{"<script", "<style"} {
|
||||||
|
withNonce := tag + ` nonce="` + nonce + `"`
|
||||||
|
if strings.Count(page, tag) != strings.Count(page, withNonce) {
|
||||||
|
t.Errorf("%s: a %s tag does not carry the nonce %q", path, tag, nonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(page, "style=") {
|
||||||
|
t.Errorf("%s: the page uses a style attribute", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardedForFromUntrustedPeerIsIgnored asks /ip/ for the caller's own
|
||||||
|
// address from a peer that is not a trusted proxy, with forwarding headers
|
||||||
|
// naming another address, and checks that the lookup is for the TCP peer.
|
||||||
|
// Before the fix any client could choose the address it was looked up and
|
||||||
|
// logged as (https://git.eeqj.de/sneak/routewatch/issues/108).
|
||||||
|
func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newServerWithTestRoutes(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ip/", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.1:1234"
|
||||||
|
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.9")
|
||||||
|
req.Header.Set("True-Client-IP", "203.0.113.9")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Data IPLookupResponse `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Data.Query != "192.0.2.1" {
|
||||||
|
t.Errorf("looked up %q, want the TCP peer 192.0.2.1; answer %s",
|
||||||
|
body.Data.Query, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies checks which address
|
||||||
|
// RealIPMiddleware gives a request: the TCP peer, unless the peer is a trusted
|
||||||
|
// proxy; then the last X-Forwarded-For entry that is not a trusted proxy,
|
||||||
|
// since the entries left of it are whatever the client sent.
|
||||||
|
func TestRealIPMiddlewareTrustsOnlyConfiguredProxies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
peer string
|
||||||
|
forwardedFor string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"untrusted peer", "192.0.2.1:1234", "203.0.113.9", "192.0.2.1:1234"},
|
||||||
|
{"trusted peer", "10.0.0.2:1234", "203.0.113.9", "203.0.113.9"},
|
||||||
|
{
|
||||||
|
"entries the client wrote", "10.0.0.2:1234",
|
||||||
|
"198.51.100.7, 203.0.113.9", "203.0.113.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"two trusted proxies", "10.0.0.2:1234",
|
||||||
|
"203.0.113.9, 10.0.0.3", "203.0.113.9",
|
||||||
|
},
|
||||||
|
{"no header", "10.0.0.2:1234", "", "10.0.0.2:1234"},
|
||||||
|
{"entry not an address", "10.0.0.2:1234", "client", "10.0.0.2:1234"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
var got string
|
||||||
|
|
||||||
|
handler := RealIPMiddleware(trusted)(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) { got = r.RemoteAddr }))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = tt.peer
|
||||||
|
|
||||||
|
if tt.forwardedFor != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", tt.forwardedFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("%s: RemoteAddr %q, want %q", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
r.Use(DecodedPathMiddleware)
|
r.Use(DecodedPathMiddleware)
|
||||||
|
r.Use(SecurityHeadersMiddleware)
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(RealIPMiddleware(s.trustedProxies))
|
||||||
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
|
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(JSONResponseMiddleware)
|
r.Use(JSONResponseMiddleware)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,6 +41,10 @@ type Server struct {
|
|||||||
port int
|
port int
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
asnFetcher ASNFetcher
|
asnFetcher ASNFetcher
|
||||||
|
|
||||||
|
// trustedProxies are the reverse proxies whose X-Forwarded-For header
|
||||||
|
// RealIPMiddleware believes
|
||||||
|
trustedProxies []netip.Prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new HTTP server
|
// New creates a new HTTP server
|
||||||
@@ -50,10 +55,11 @@ func New(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *Server {
|
) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
db: db,
|
db: db,
|
||||||
streamer: streamer,
|
streamer: streamer,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
port: cfg.Port,
|
port: cfg.Port,
|
||||||
|
trustedProxies: cfg.TrustedProxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AS{{.ASN.ASN}} - {{.ASN.Handle}} - RouteWatch</title>
|
<title>AS{{.ASN.ASN}} - {{.ASN.Handle}} - RouteWatch</title>
|
||||||
<style>
|
<style nonce="{{.CSPNonce}}">
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RouteWatch - BGP Route Monitor</title>
|
<title>RouteWatch - BGP Route Monitor</title>
|
||||||
<style>
|
<style nonce="{{.CSPNonce}}">
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,13 @@
|
|||||||
color: #95a5a6;
|
color: #95a5a6;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
#asname-results p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
#asname-results .search-message {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* IP Lookup result */
|
/* IP Lookup result */
|
||||||
.ip-result {
|
.ip-result {
|
||||||
@@ -329,7 +336,7 @@
|
|||||||
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
|
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{.CSPNonce}}">
|
||||||
function formatNumber(num) {
|
function formatNumber(num) {
|
||||||
if (num >= 1000000) {
|
if (num >= 1000000) {
|
||||||
return (num / 1000000).toFixed(1) + 'M';
|
return (num / 1000000).toFixed(1) + 'M';
|
||||||
@@ -387,7 +394,7 @@
|
|||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('asname-results');
|
const resultsDiv = document.getElementById('asname-results');
|
||||||
resultsDiv.innerHTML = '<p class="loading" style="color: #7f8c8d; margin-top: 12px;">Searching...</p>';
|
resultsDiv.innerHTML = '<p class="loading">Searching...</p>';
|
||||||
|
|
||||||
// Use a simple client-side search against the asinfo data
|
// Use a simple client-side search against the asinfo data
|
||||||
// For now, redirect to AS page if it looks like an ASN
|
// For now, redirect to AS page if it looks like an ASN
|
||||||
@@ -397,7 +404,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show a message that server-side search is coming
|
// Show a message that server-side search is coming
|
||||||
resultsDiv.innerHTML = '<p style="color: #7f8c8d; margin-top: 12px; font-size: 13px;">AS name search coming soon. For now, try an AS number.</p>';
|
resultsDiv.innerHTML = '<p class="search-message">AS name search coming soon. For now, try an AS number.</p>';
|
||||||
});
|
});
|
||||||
|
|
||||||
// IP lookup
|
// IP lookup
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.Prefix}} - RouteWatch</title>
|
<title>{{.Prefix}} - RouteWatch</title>
|
||||||
<style>
|
<style nonce="{{.CSPNonce}}">
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -221,6 +221,10 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
.origin-item .peer-count {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
@@ -288,7 +292,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
No known origin
|
No known origin
|
||||||
{{end}}
|
{{end}}
|
||||||
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
|
<span class="peer-count">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Prefixes with /{{ .MaskLength }} - RouteWatch</title>
|
<title>Prefixes with /{{ .MaskLength }} - RouteWatch</title>
|
||||||
<style>
|
<style nonce="{{.CSPNonce}}">
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RouteWatch Status</title>
|
<title>RouteWatch Status</title>
|
||||||
<style>
|
<style nonce="{{.CSPNonce}}">
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -130,6 +130,7 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
|
display: none;
|
||||||
background: #fee;
|
background: #fee;
|
||||||
color: #c00;
|
color: #c00;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div id="error" class="error" style="display: none;"></div>
|
<div id="error" class="error"></div>
|
||||||
<div class="status-grid">
|
<div class="status-grid">
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h2>RouteWatch</h2>
|
<h2>RouteWatch</h2>
|
||||||
@@ -384,7 +385,7 @@
|
|||||||
<!-- Handler stats will be dynamically added here -->
|
<!-- Handler stats will be dynamically added here -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{.CSPNonce}}">
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
Reference in New Issue
Block a user