Compare commits
1 Commits
fix/98-sec
...
fix/117-bo
| Author | SHA1 | Date | |
|---|---|---|---|
| db933f32a6 |
43
README.md
43
README.md
@@ -182,46 +182,6 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
|||||||
| `GET /api/v1/status` | Current monitoring state |
|
| `GET /api/v1/status` | Current monitoring state |
|
||||||
| `GET /metrics` | Prometheus metrics (optional) |
|
| `GET /metrics` | Prometheus metrics (optional) |
|
||||||
|
|
||||||
### Security Headers
|
|
||||||
|
|
||||||
Every response — the dashboard, the static assets under `/s/...`, the
|
|
||||||
healthchecks, the JSON API, and `/metrics` — carries the following
|
|
||||||
headers, set by a global middleware:
|
|
||||||
|
|
||||||
| Header | Value |
|
|
||||||
|-----------------------------|---------------------------------------|
|
|
||||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
|
|
||||||
| `Content-Security-Policy` | see below |
|
|
||||||
| `X-Frame-Options` | `DENY` |
|
|
||||||
| `X-Content-Type-Options` | `nosniff` |
|
|
||||||
| `Referrer-Policy` | `no-referrer` |
|
|
||||||
| `Permissions-Policy` | all unused browser features denied |
|
|
||||||
|
|
||||||
The content security policy is:
|
|
||||||
|
|
||||||
```
|
|
||||||
default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self';
|
|
||||||
font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none';
|
|
||||||
form-action 'none'; frame-ancestors 'none'
|
|
||||||
```
|
|
||||||
|
|
||||||
The dashboard ships no JavaScript (the 30-second refresh is a
|
|
||||||
`<meta http-equiv="refresh">`), no inline styles, no inline event
|
|
||||||
handlers, and no images; its only subresource is the embedded stylesheet
|
|
||||||
at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The
|
|
||||||
policy therefore needs neither `unsafe-inline` nor `unsafe-eval`.
|
|
||||||
`frame-ancestors 'none'` is the primary anti-framing control, with
|
|
||||||
`X-Frame-Options: DENY` retained as the legacy fallback.
|
|
||||||
|
|
||||||
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
|
|
||||||
expected to run behind a TLS-terminating reverse proxy, and the browser
|
|
||||||
must still be told to enforce HTTPS end to end, so the header is never
|
|
||||||
gated on whether the request itself arrived over TLS.
|
|
||||||
|
|
||||||
`Referrer-Policy: no-referrer` is stricter than the
|
|
||||||
`strict-origin-when-cross-origin` baseline: the dashboard has no
|
|
||||||
cross-origin navigation needs, and its URL may name internal hosts.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -234,8 +194,7 @@ internal/
|
|||||||
globals/globals.go Build-time variables (version)
|
globals/globals.go Build-time variables (version)
|
||||||
logger/logger.go slog structured logging (TTY detection)
|
logger/logger.go slog structured logging (TTY detection)
|
||||||
healthcheck/healthcheck.go Health check service
|
healthcheck/healthcheck.go Health check service
|
||||||
middleware/middleware.go HTTP middleware (logging, CORS, security
|
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
|
||||||
headers, metrics auth)
|
|
||||||
handlers/handlers.go HTTP request handlers
|
handlers/handlers.go HTTP request handlers
|
||||||
server/
|
server/
|
||||||
server.go HTTP server lifecycle
|
server.go HTTP server lifecycle
|
||||||
|
|||||||
17
TODO.md
17
TODO.md
@@ -25,16 +25,13 @@ confirm make check still passes.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-09: security response headers middleware
|
- 2026-08-09: `script/bootstrap` now installs the pinned `golangci-lint`
|
||||||
(`SecurityHeaders()` in `internal/middleware/middleware.go`)
|
and `goimports` unconditionally instead of only when the binary is
|
||||||
registered globally in `internal/server/routes.go`, so HSTS, CSP,
|
absent from `PATH`, so the commit pins actually take effect on
|
||||||
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and
|
already-provisioned machines; it also warns when `PATH` resolves
|
||||||
`Permissions-Policy` are set on every response including `/s/...` and
|
either tool to a copy outside the directory `go install` writes to.
|
||||||
`/metrics`; the CSP needs no `unsafe-inline`/`unsafe-eval` because the
|
The `missing` presence check is retained for `git`, `make`, and `go`
|
||||||
dashboard ships no JavaScript and no inline styles; HSTS is emitted
|
(#117)
|
||||||
unconditionally per policy (TLS-terminating proxy in front). Remaining
|
|
||||||
1.0 hardening items — `http.Server` timeouts, request body limits,
|
|
||||||
rate limiting, CORS scoping — are tracked separately
|
|
||||||
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
||||||
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
|
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
|
||||||
org-standard v2-schema config used across the org's repos
|
org-standard v2-schema config used across the org's repos
|
||||||
|
|||||||
@@ -21,60 +21,6 @@ import (
|
|||||||
// corsMaxAge is the maximum age for CORS preflight responses.
|
// corsMaxAge is the maximum age for CORS preflight responses.
|
||||||
const corsMaxAge = 300
|
const corsMaxAge = 300
|
||||||
|
|
||||||
// Security response header values applied to every response.
|
|
||||||
//
|
|
||||||
// The CSP is as strict as the dashboard allows: the template ships no
|
|
||||||
// JavaScript, no inline styles, no inline event handlers and no images,
|
|
||||||
// and its only subresource is the embedded stylesheet at
|
|
||||||
// /s/css/tailwind.min.css, which style-src 'self' permits. Neither
|
|
||||||
// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the
|
|
||||||
// primary anti-framing control; X-Frame-Options is the legacy fallback.
|
|
||||||
const (
|
|
||||||
// hstsValue is emitted unconditionally, including over plain HTTP,
|
|
||||||
// because the service runs behind a TLS-terminating proxy and the
|
|
||||||
// browser must still enforce HTTPS end to end.
|
|
||||||
hstsValue = "max-age=31536000; includeSubDomains"
|
|
||||||
|
|
||||||
cspValue = "default-src 'self'; " +
|
|
||||||
"script-src 'none'; " +
|
|
||||||
"style-src 'self'; " +
|
|
||||||
"img-src 'self'; " +
|
|
||||||
"font-src 'none'; " +
|
|
||||||
"connect-src 'none'; " +
|
|
||||||
"object-src 'none'; " +
|
|
||||||
"base-uri 'none'; " +
|
|
||||||
"form-action 'none'; " +
|
|
||||||
"frame-ancestors 'none'"
|
|
||||||
|
|
||||||
frameOptionsValue = "DENY"
|
|
||||||
|
|
||||||
contentTypeOptionsValue = "nosniff"
|
|
||||||
|
|
||||||
// referrerPolicyValue is stricter than the policy minimum of
|
|
||||||
// strict-origin-when-cross-origin: the dashboard has no
|
|
||||||
// cross-origin navigation needs and its URL may name internal
|
|
||||||
// hosts.
|
|
||||||
referrerPolicyValue = "no-referrer"
|
|
||||||
|
|
||||||
permissionsPolicyValue = "accelerometer=(), " +
|
|
||||||
"autoplay=(), " +
|
|
||||||
"camera=(), " +
|
|
||||||
"display-capture=(), " +
|
|
||||||
"encrypted-media=(), " +
|
|
||||||
"fullscreen=(), " +
|
|
||||||
"geolocation=(), " +
|
|
||||||
"gyroscope=(), " +
|
|
||||||
"magnetometer=(), " +
|
|
||||||
"microphone=(), " +
|
|
||||||
"midi=(), " +
|
|
||||||
"payment=(), " +
|
|
||||||
"picture-in-picture=(), " +
|
|
||||||
"publickey-credentials-get=(), " +
|
|
||||||
"screen-wake-lock=(), " +
|
|
||||||
"usb=(), " +
|
|
||||||
"xr-spatial-tracking=()"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Params contains dependencies for Middleware.
|
// Params contains dependencies for Middleware.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -240,37 +186,6 @@ func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeaders returns middleware that sets the security response
|
|
||||||
// headers required for production internet exposure on every response.
|
|
||||||
//
|
|
||||||
// The headers are set before the request reaches the next handler so
|
|
||||||
// that they are present on every response, including panics recovered
|
|
||||||
// by chi's Recoverer and timeouts produced by chi's Timeout.
|
|
||||||
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
header := writer.Header()
|
|
||||||
header.Set("Strict-Transport-Security", hstsValue)
|
|
||||||
header.Set("Content-Security-Policy", cspValue)
|
|
||||||
header.Set("X-Frame-Options", frameOptionsValue)
|
|
||||||
header.Set(
|
|
||||||
"X-Content-Type-Options",
|
|
||||||
contentTypeOptionsValue,
|
|
||||||
)
|
|
||||||
header.Set("Referrer-Policy", referrerPolicyValue)
|
|
||||||
header.Set(
|
|
||||||
"Permissions-Policy",
|
|
||||||
permissionsPolicyValue,
|
|
||||||
)
|
|
||||||
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MetricsAuth returns basic auth middleware for /metrics.
|
// MetricsAuth returns basic auth middleware for /metrics.
|
||||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
if m.params.Config.MetricsUsername == "" {
|
if m.params.Config.MetricsUsername == "" {
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/config"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/middleware"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expected security header values, spelled out literally so that any
|
|
||||||
// change to the middleware has to be made deliberately here as well.
|
|
||||||
const (
|
|
||||||
wantHSTS = "max-age=31536000; includeSubDomains"
|
|
||||||
|
|
||||||
wantCSP = "default-src 'self'; " +
|
|
||||||
"script-src 'none'; " +
|
|
||||||
"style-src 'self'; " +
|
|
||||||
"img-src 'self'; " +
|
|
||||||
"font-src 'none'; " +
|
|
||||||
"connect-src 'none'; " +
|
|
||||||
"object-src 'none'; " +
|
|
||||||
"base-uri 'none'; " +
|
|
||||||
"form-action 'none'; " +
|
|
||||||
"frame-ancestors 'none'"
|
|
||||||
|
|
||||||
wantFrameOptions = "DENY"
|
|
||||||
|
|
||||||
wantContentTypeOptions = "nosniff"
|
|
||||||
|
|
||||||
wantReferrerPolicy = "no-referrer"
|
|
||||||
|
|
||||||
wantPermissionsPolicy = "accelerometer=(), " +
|
|
||||||
"autoplay=(), " +
|
|
||||||
"camera=(), " +
|
|
||||||
"display-capture=(), " +
|
|
||||||
"encrypted-media=(), " +
|
|
||||||
"fullscreen=(), " +
|
|
||||||
"geolocation=(), " +
|
|
||||||
"gyroscope=(), " +
|
|
||||||
"magnetometer=(), " +
|
|
||||||
"microphone=(), " +
|
|
||||||
"midi=(), " +
|
|
||||||
"payment=(), " +
|
|
||||||
"picture-in-picture=(), " +
|
|
||||||
"publickey-credentials-get=(), " +
|
|
||||||
"screen-wake-lock=(), " +
|
|
||||||
"usb=(), " +
|
|
||||||
"xr-spatial-tracking=()"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stylesheetPath is the only subresource the dashboard loads.
|
|
||||||
const stylesheetPath = "/s/css/tailwind.min.css"
|
|
||||||
|
|
||||||
// newTestLogger builds a logger for direct component construction.
|
|
||||||
func newTestLogger(t *testing.T) *logger.Logger {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
glob, err := globals.New(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("globals.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log, err := logger.New(nil, logger.Params{Globals: glob})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return log
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestMiddleware builds a Middleware without an fx application.
|
|
||||||
func newTestMiddleware(t *testing.T) *middleware.Middleware {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
glob, err := globals.New(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("globals.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mw, err := middleware.New(nil, middleware.Params{
|
|
||||||
Logger: newTestLogger(t),
|
|
||||||
Globals: glob,
|
|
||||||
Config: &config.Config{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("middleware.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mw
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveWithSecurityHeaders runs a GET through SecurityHeaders and
|
|
||||||
// returns the recorded response.
|
|
||||||
func serveWithSecurityHeaders(
|
|
||||||
t *testing.T,
|
|
||||||
target string,
|
|
||||||
handler http.Handler,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mw := newTestMiddleware(t)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, target, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
mw.SecurityHeaders()(handler).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// okHandler writes a trivial 200 response.
|
|
||||||
func okHandler() http.Handler {
|
|
||||||
return http.HandlerFunc(func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
_ *http.Request,
|
|
||||||
) {
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
header string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"hsts",
|
|
||||||
"Strict-Transport-Security",
|
|
||||||
wantHSTS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"csp",
|
|
||||||
"Content-Security-Policy",
|
|
||||||
wantCSP,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"frame options",
|
|
||||||
"X-Frame-Options",
|
|
||||||
wantFrameOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content type options",
|
|
||||||
"X-Content-Type-Options",
|
|
||||||
wantContentTypeOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"referrer policy",
|
|
||||||
"Referrer-Policy",
|
|
||||||
wantReferrerPolicy,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"permissions policy",
|
|
||||||
"Permissions-Policy",
|
|
||||||
wantPermissionsPolicy,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
|
||||||
|
|
||||||
got := rec.Header().Get(tt.header)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf(
|
|
||||||
"%s = %q, want %q",
|
|
||||||
tt.header, got, tt.want,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecurityHeadersCSPDirectives guards the properties the repo
|
|
||||||
// policy requires of the content security policy itself.
|
|
||||||
func TestSecurityHeadersCSPDirectives(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
|
||||||
csp := rec.Header().Get("Content-Security-Policy")
|
|
||||||
|
|
||||||
forbidden := []string{"unsafe-inline", "unsafe-eval"}
|
|
||||||
for _, directive := range forbidden {
|
|
||||||
if strings.Contains(csp, directive) {
|
|
||||||
t.Errorf("CSP must not contain %q: %q", directive, csp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
required := []string{
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'none'",
|
|
||||||
"style-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
}
|
|
||||||
for _, directive := range required {
|
|
||||||
if !strings.Contains(csp, directive) {
|
|
||||||
t.Errorf("CSP must contain %q: %q", directive, csp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecurityHeadersOnErrorResponse verifies the headers are emitted
|
|
||||||
// even when the wrapped handler fails, since they are set before the
|
|
||||||
// handler runs.
|
|
||||||
func TestSecurityHeadersOnErrorResponse(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
failing := http.HandlerFunc(func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
_ *http.Request,
|
|
||||||
) {
|
|
||||||
http.Error(
|
|
||||||
writer,
|
|
||||||
"boom",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
rec := serveWithSecurityHeaders(t, "/api/v1/status", failing)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusInternalServerError {
|
|
||||||
t.Fatalf("status = %d, want 500", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := rec.Header().Get(
|
|
||||||
"X-Content-Type-Options",
|
|
||||||
); got != wantContentTypeOptions {
|
|
||||||
t.Errorf(
|
|
||||||
"X-Content-Type-Options = %q, want %q",
|
|
||||||
got, wantContentTypeOptions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := rec.Header().Get(
|
|
||||||
"Strict-Transport-Security",
|
|
||||||
); got != wantHSTS {
|
|
||||||
t.Errorf(
|
|
||||||
"Strict-Transport-Security = %q, want %q",
|
|
||||||
got, wantHSTS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestHandlers builds real Handlers with empty monitoring state.
|
|
||||||
func newTestHandlers(t *testing.T) *handlers.Handlers {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
glob, err := globals.New(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("globals.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log := newTestLogger(t)
|
|
||||||
|
|
||||||
notifier, err := notify.New(nil, notify.Params{
|
|
||||||
Logger: log,
|
|
||||||
Config: &config.Config{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("notify.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hnd, err := handlers.New(nil, handlers.Params{
|
|
||||||
Logger: log,
|
|
||||||
Globals: glob,
|
|
||||||
State: state.NewForTest(),
|
|
||||||
Notify: notifier,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("handlers.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hnd
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDashboardRendersWithSecurityHeaders renders the real dashboard
|
|
||||||
// through the middleware and checks that the policy still permits the
|
|
||||||
// one stylesheet the page loads.
|
|
||||||
func TestDashboardRendersWithSecurityHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mw := newTestMiddleware(t)
|
|
||||||
hnd := newTestHandlers(t)
|
|
||||||
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Use(mw.SecurityHeaders())
|
|
||||||
router.Get("/", hnd.HandleDashboard())
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, stylesheetPath) {
|
|
||||||
t.Errorf("dashboard does not reference %q", stylesheetPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(body, "dnswatcher") {
|
|
||||||
t.Errorf("dashboard body looks empty: %d bytes", len(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
csp := rec.Header().Get("Content-Security-Policy")
|
|
||||||
if csp != wantCSP {
|
|
||||||
t.Errorf("CSP = %q, want %q", csp, wantCSP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The stylesheet is same-origin, so style-src 'self' allows it.
|
|
||||||
if !strings.Contains(csp, "style-src 'self'") {
|
|
||||||
t.Errorf("CSP would block %q: %q", stylesheetPath, csp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Global middleware
|
// Global middleware
|
||||||
s.router.Use(chimw.Recoverer)
|
s.router.Use(chimw.Recoverer)
|
||||||
s.router.Use(chimw.RequestID)
|
s.router.Use(chimw.RequestID)
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(chimw.Timeout(requestTimeout))
|
s.router.Use(chimw.Timeout(requestTimeout))
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/bootstrap: install all dependencies needed to build and develop
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Base tooling (git, make, go) comes from nix, apt, brew, or
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# apk (detected in that order) and is installed only when absent;
|
||||||
# or apk (detected in that order); assumes nothing is present.
|
# assumes nothing is present. golangci-lint and goimports are always
|
||||||
# golangci-lint and goimports are installed via `go install` at the same
|
# (re)installed via `go install` at the same pinned commits the
|
||||||
# pinned commits the Dockerfile uses (never "latest").
|
# Dockerfile uses (never "latest") -- a presence check cannot tell the
|
||||||
|
# pinned build from an arbitrary one already on PATH, so guarding them
|
||||||
|
# would make the pins inert. Idempotent either way: running this twice
|
||||||
|
# succeeds both times and leaves the same result.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -62,6 +65,31 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# go_bin_dir: directory `go install` writes binaries to.
|
||||||
|
go_bin_dir() {
|
||||||
|
gobin="$(go env GOBIN)"
|
||||||
|
if [ -n "$gobin" ]; then
|
||||||
|
echo "$gobin"
|
||||||
|
else
|
||||||
|
echo "$(go env GOPATH)/bin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# warn_if_shadowed <tool> <dir>: the pinned build was just installed
|
||||||
|
# into <dir>. If PATH resolves <tool> anywhere else, that other copy is
|
||||||
|
# what `make lint` and `make fmt` will actually run, and it is not the
|
||||||
|
# pinned version. Warn loudly rather than failing, since the fix is the
|
||||||
|
# user's PATH and not anything this script can do.
|
||||||
|
warn_if_shadowed() {
|
||||||
|
resolved="$(command -v "$1" 2>/dev/null || true)"
|
||||||
|
if [ "$resolved" != "$2/$1" ]; then
|
||||||
|
echo "bootstrap: WARNING: installed pinned $1 to $2/$1, but PATH" >&2
|
||||||
|
echo "bootstrap: WARNING: resolves $1 to ${resolved:-(not on PATH)};" >&2
|
||||||
|
echo "bootstrap: WARNING: put $2 first on PATH or lint results will" >&2
|
||||||
|
echo "bootstrap: WARNING: not match CI." >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -69,10 +97,17 @@ main() {
|
|||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Lint/format tools, pinned via go install (installs into
|
# Lint/format tools, pinned via go install. These are installed
|
||||||
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
|
# unconditionally: `command -v` only proves *some* build is on PATH,
|
||||||
if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi
|
# and a wrong golangci-lint either cannot parse our v2-schema
|
||||||
if missing goimports; then go install "$GOIMPORTS_REF"; fi
|
# .golangci.yml at all or silently disagrees with CI. Installing at
|
||||||
|
# a fixed commit ref is idempotent and cheap with a warm module
|
||||||
|
# cache, so there is nothing to save by skipping it.
|
||||||
|
GOBIN_DIR="$(go_bin_dir)"
|
||||||
|
go install "$GOLANGCI_LINT_REF"
|
||||||
|
go install "$GOIMPORTS_REF"
|
||||||
|
warn_if_shadowed golangci-lint "$GOBIN_DIR"
|
||||||
|
warn_if_shadowed goimports "$GOBIN_DIR"
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user