Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c1bffb5a | ||
|
|
557b4f621a | ||
|
|
3cfcda0730 | ||
|
|
1798cba96c | ||
|
|
37d49ade11 |
+5
-2
@@ -13,9 +13,12 @@ RUN go mod download
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run formatting check and linter
|
||||
# Run formatting check and linter. The linter is invoked directly, not
|
||||
# via `make lint`: `make lint` now builds Dockerfile.lint, and there is
|
||||
# no Docker inside a Docker build. This is the same linter, image, and
|
||||
# config that Dockerfile.lint and script/lint run.
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage
|
||||
# golang:1.25.4-alpine, 2026-02-25
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Dockerfile.lint: the one and only path that runs golangci-lint.
|
||||
#
|
||||
# golangci-lint is never installed on the host; it runs only inside this
|
||||
# build. A clean build of this file therefore IS a clean lint over the
|
||||
# whole tree. It runs the same linter and config as Dockerfile's lint
|
||||
# stage, pinned to the same image so the two cannot drift to different
|
||||
# linter versions.
|
||||
#
|
||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
||||
|
||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
||||
# this image needs the same C libraries the build does.
|
||||
RUN apk add --no-cache build-base vips-dev libheif-dev pkgconfig
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Modules first for layer caching; go.mod/go.sum settle this layer's
|
||||
# result, so it may safely be reused between runs.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Caching is deliberately waived for the lint step: an unchanged tree
|
||||
# must still run the linter, not return a cached success in well under a
|
||||
# second having linted nothing. CACHEBUST carries a value that differs
|
||||
# on every run (script/lint supplies it and refuses to build without
|
||||
# one). The lint RUN below references it, so BuildKit cannot serve that
|
||||
# step from cache. Keep the ${CACHEBUST} reference on that step: dropping
|
||||
# it lets the linter cache again and report a green that linted nothing.
|
||||
ARG CACHEBUST
|
||||
RUN test -n "${CACHEBUST}" || { \
|
||||
echo "Dockerfile.lint requires the CACHEBUST build-arg; build it via script/lint." >&2; \
|
||||
exit 1; }
|
||||
|
||||
# `golangci-lint config verify` is deliberately not run: it fetches its
|
||||
# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md
|
||||
# forbids for external references.
|
||||
RUN echo "pixa-lint: running golangci-lint (${CACHEBUST})" && \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
@@ -10,7 +10,7 @@ ifdef HAS_PKGCONFIG
|
||||
NIX_RUN_PREFIX =
|
||||
NIX_RUN_SUFFIX =
|
||||
else
|
||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run '
|
||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run '
|
||||
NIX_RUN_SUFFIX = '
|
||||
endif
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ $EDITOR config.yml # replace the signing_key placeholder
|
||||
|
||||
# or build and run via Docker
|
||||
make docker
|
||||
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixad:latest
|
||||
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
||||
```
|
||||
|
||||
A container is configured two ways. The signing key comes from the
|
||||
@@ -122,6 +122,10 @@ Configured via YAML file (`--config`). Key settings:
|
||||
|
||||
- `access_control_allow_origin` — CORS origin
|
||||
- `allowlist_hosts` — list of allowed upstream hosts
|
||||
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
||||
added to the always-enforced built-in ranges (loopback, private,
|
||||
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
|
||||
aborts startup
|
||||
- `upstream_fetch_timeout` — timeout for origin requests
|
||||
- `upstream_max_response_size` — max origin response size
|
||||
- `downstream_timeout` — client response timeout
|
||||
|
||||
@@ -25,10 +25,39 @@ The disk cache is now size-bounded with LRU eviction
|
||||
|
||||
# Next Step
|
||||
|
||||
P1: implement blocked networks configuration to extend SSRF protection
|
||||
P1: rate limit global concurrent upstream fetches to prevent resource
|
||||
exhaustion
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-21 blocked networks configuration extending SSRF protection: a
|
||||
`blocked_networks` config key taking a list of CIDRs (parsed with
|
||||
`net/netip`, an invalid entry aborts startup naming the key and value),
|
||||
added to the built-in blocklist rather than replacing it; the built-in
|
||||
ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments
|
||||
`192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96`
|
||||
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
||||
re-resolution so the DNS-rebinding window remains closed; documented in
|
||||
`README.md` and `config.example.yml`.
|
||||
- 2026-09-21 run all linting in Docker via `Dockerfile.lint` +
|
||||
`script/lint` (closes #104): `script/lint` builds a hash-pinned root
|
||||
`Dockerfile.lint`, and no host or nix-shell `golangci-lint` path
|
||||
remains (`script/bootstrap` installs no linter, and the Makefile and
|
||||
`script/test` nix-shell package lists carry only build/test deps); a
|
||||
per-run `CACHEBUST` build-arg forces the lint step to execute every
|
||||
run, so an unchanged tree cannot return a cached green that linted
|
||||
nothing; `Dockerfile`'s lint stage runs `golangci-lint` directly,
|
||||
since `make lint` now builds a container and there is no Docker inside
|
||||
a build; `golangci-lint config verify` stays out, as it fetches its
|
||||
schema over an unpinned live HTTPS call
|
||||
- 2026-09-21 http.Server hardening (closes #92): added
|
||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||
existing timeouts and wired them onto the server; added a `LimitBody`
|
||||
middleware capping the two form POST bodies (`POST /`, `POST /generate`)
|
||||
at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF
|
||||
middleware so an oversized body is refused as 413 rather than being read
|
||||
as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged
|
||||
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical
|
||||
`.golangci.yml` (v2 schema, `default: all` minus six disabled
|
||||
linters, `lll` 88, tests included): bumped the pinned
|
||||
@@ -122,8 +151,6 @@ P1: implement blocked networks configuration to extend SSRF protection
|
||||
|
||||
# Future Steps
|
||||
|
||||
- P1: rate limit global concurrent upstream fetches to prevent
|
||||
resource exhaustion
|
||||
- P1: strip EXIF and other metadata from processed images (privacy)
|
||||
- P2: security
|
||||
- referer blacklist
|
||||
|
||||
@@ -22,6 +22,15 @@ allowlist_hosts:
|
||||
- github.com
|
||||
- user-images.githubusercontent.com
|
||||
|
||||
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
||||
# SSRF protection. These are added to the always-enforced built-in ranges
|
||||
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
|
||||
# similar), never replacing them. Each entry must be a valid CIDR in IPv4
|
||||
# or IPv6 form; an invalid entry aborts startup.
|
||||
# blocked_networks:
|
||||
# - 100.64.0.0/10
|
||||
# - 2001:db8::/32
|
||||
|
||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||
allow_http: false
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
|
||||
// each CIDR is parsed into the resolved prefixes in order.
|
||||
func TestBlockedNetworksParsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine + `blocked_networks:
|
||||
- 203.0.113.0/24
|
||||
- 2001:db8::/32
|
||||
`
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
if err != nil {
|
||||
t.Fatalf("valid blocked_networks should load, got error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"203.0.113.0/24", "2001:db8::/32"}
|
||||
if len(c.BlockedNetworks) != len(want) {
|
||||
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
||||
}
|
||||
|
||||
for i, w := range want {
|
||||
if got := c.BlockedNetworks[i].String(); got != w {
|
||||
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
|
||||
// operator list empty; the built-in defaults still apply in the fetcher.
|
||||
func TestBlockedNetworksOmittedIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine)
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(c.BlockedNetworks) != 0 {
|
||||
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
|
||||
// startup with an error naming the key and the offending value.
|
||||
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runAbortCases(t, []abortCase{
|
||||
{
|
||||
name: "not-a-cidr",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
- not-a-cidr
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
|
||||
},
|
||||
{
|
||||
name: "bare-address-without-prefix",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
- 10.0.0.1
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
|
||||
},
|
||||
{
|
||||
name: "empty-entry",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
- ""
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||
},
|
||||
{
|
||||
name: "non-string-entry",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
- 42
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||
},
|
||||
{
|
||||
name: "null-value",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
|
||||
},
|
||||
})
|
||||
}
|
||||
+101
-3
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,6 +43,7 @@ const (
|
||||
keyAllowHTTP = "allow_http"
|
||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
keyBlockedNetworks = "blocked_networks"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
@@ -60,6 +62,7 @@ var (
|
||||
errNotAnInteger = errors.New("not an integer")
|
||||
errNotABoolean = errors.New("not a boolean")
|
||||
errNotAStringList = errors.New("not a list of strings")
|
||||
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||
errEmptyEntry = errors.New("contains an empty entry")
|
||||
@@ -109,6 +112,11 @@ type Config struct {
|
||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||
|
||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||
// fetcher's dialer; the built-in ranges always apply.
|
||||
BlockedNetworks []netip.Prefix
|
||||
|
||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||
// disables the disk cache entirely. When cache_max_bytes is
|
||||
// omitted from the configuration, this holds the computed default
|
||||
@@ -177,6 +185,11 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
blockedNetworks, err := getBlockedNetworks(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loader := &strictLoader{sc: sc}
|
||||
|
||||
c := &Config{
|
||||
@@ -192,7 +205,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||
UpstreamConnectionsPerHost: loader.intVal(
|
||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
BlockedNetworks: blockedNetworks,
|
||||
}
|
||||
|
||||
// The computed default for cache_max_bytes needs a validated
|
||||
@@ -224,7 +238,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
return nil, loader.err
|
||||
}
|
||||
|
||||
err := c.validate()
|
||||
err = c.validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -308,7 +322,7 @@ func isKnownConfigKey(key string) bool {
|
||||
switch key {
|
||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks, "env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -802,3 +816,87 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBlockedNetworks parses the blocked_networks value into CIDR prefixes,
|
||||
// or returns nil if the key is omitted. It accepts a YAML list of strings
|
||||
// or a comma-separated string. An explicitly null value, a wrong type, an
|
||||
// empty entry, a non-string entry, or an unparseable CIDR aborts startup
|
||||
// naming the key and the offending value; a default (the built-in
|
||||
// blocklist alone) applies only to an omitted key.
|
||||
func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
if sc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, ok := sc.Get(keyBlockedNetworks)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
return nil, errNullConfigValue(keyBlockedNetworks)
|
||||
}
|
||||
|
||||
entries, err := blockedNetworkEntries(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefixes := make([]netip.Prefix, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
prefix, err := netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config key %q: value %q is %w",
|
||||
keyBlockedNetworks, entry, errNotAValidCIDR)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// blockedNetworkEntries extracts the raw blocked_networks entries as
|
||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
||||
// comma-separated string. Any other shape is a configuration error.
|
||||
func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
switch val := raw.(type) {
|
||||
case []any:
|
||||
entries := make([]string, 0, len(val))
|
||||
|
||||
for _, item := range val {
|
||||
str, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
||||
keyBlockedNetworks, item, item, errNotAString)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(str) == "" {
|
||||
return nil, fmt.Errorf("config key %q: %w",
|
||||
keyBlockedNetworks, errEmptyListEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, strings.TrimSpace(str))
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
case string:
|
||||
entries := make([]string, 0)
|
||||
|
||||
for part := range strings.SplitSeq(val, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("config key %q: value %q %w",
|
||||
keyBlockedNetworks, val, errEmptyEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, trimmed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
keyBlockedNetworks, raw, raw, errNotAStringList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MaxFormBytes bounds the request body accepted on the HTML form POST
|
||||
// routes (POST / and POST /generate). The forms carry a handful of short
|
||||
// fields, so 1 MiB is generous while making the bound explicit rather than
|
||||
// resting on ParseForm's incidental 10 MB cap.
|
||||
const MaxFormBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// LimitBody returns middleware that caps the request body on POST requests
|
||||
// at maxBytes and rejects an oversized body with 413 Request Entity Too
|
||||
// Large.
|
||||
//
|
||||
// It parses the form here, before the CSRF middleware reads the token from
|
||||
// it. The CSRF middleware reads the token with PostFormValue, which
|
||||
// swallows a parse error, so if the body were only capped there an
|
||||
// oversized body would read as a missing token and be refused as 403. By
|
||||
// parsing under the cap first, an oversized body is refused as 413. A
|
||||
// successful parse is cached on the request, so the CSRF check and the
|
||||
// handler reuse it rather than reading the body again.
|
||||
func (s *Handlers) LimitBody(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
|
||||
err := r.ParseForm()
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
http.Error(w, "Request body too large",
|
||||
http.StatusRequestEntityTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/session"
|
||||
)
|
||||
|
||||
// Form field names and a throwaway source image URL for the body-limit
|
||||
// tests.
|
||||
const (
|
||||
sourceURLField = "url"
|
||||
testSourceURL = "https://example.com/a.jpg"
|
||||
)
|
||||
|
||||
// newBodyLimitTestRouter mirrors the production wiring for the form POST
|
||||
// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF
|
||||
// middleware, which sits in front of the handlers. maxBytes is the body
|
||||
// cap under test, so a test can trip the limit with a small body.
|
||||
func newBodyLimitTestRouter(
|
||||
t *testing.T, maxBytes int64,
|
||||
) (*Handlers, http.Handler) {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||
|
||||
sessMgr, err := session.NewManager(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("session.NewManager() error = %v", err)
|
||||
}
|
||||
|
||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||
}
|
||||
|
||||
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||
if err != nil {
|
||||
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
config: cfg,
|
||||
sessMgr: sessMgr,
|
||||
encGen: encGen,
|
||||
csrfProtect: protect,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.LimitBody(maxBytes))
|
||||
r.Use(h.CSRF())
|
||||
r.Get("/", h.HandleRoot())
|
||||
r.Post("/", h.HandleRoot())
|
||||
r.Post("/generate", h.HandleGenerateURL())
|
||||
})
|
||||
|
||||
return h, r
|
||||
}
|
||||
|
||||
// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an
|
||||
// oversized POST / carrying an otherwise valid CSRF cookie and token must
|
||||
// be rejected with 413. If the body limit ran after CSRF, the truncated
|
||||
// body would read as a missing token and return 403; if it ran after the
|
||||
// handler, a valid token would return 303. Getting 413 proves the limit
|
||||
// fires before CSRF parses the form.
|
||||
func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newBodyLimitTestRouter(t, 16)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/", cookies, url.Values{
|
||||
loginKeyField: {testSigningKey},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized POST / status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for
|
||||
// POST /generate, which also parses a form behind CSRF.
|
||||
func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newBodyLimitTestRouter(t, 16)
|
||||
|
||||
sessionCookie := newSessionCookie(t, h)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||
cookies = append(cookies, sessionCookie)
|
||||
|
||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||
sourceURLField: {testSourceURL},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized POST /generate status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a
|
||||
// normal request: under the production cap, a valid login still parses and
|
||||
// establishes a session (303). This guards against the body limit
|
||||
// consuming or corrupting the form the CSRF check and handler depend on.
|
||||
func TestWithinLimitLoginPostSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/", cookies, url.Values{
|
||||
loginKeyField: {testSigningKey},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("within-limit POST / status = %d, want %d",
|
||||
rec.Code, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
var authed bool
|
||||
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == session.CookieName && c.Value != "" {
|
||||
authed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !authed {
|
||||
t.Error("within-limit valid login did not set a session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithinLimitGeneratePostSucceeds is the same non-regression check for
|
||||
// POST /generate.
|
||||
func TestWithinLimitGeneratePostSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||
|
||||
sessionCookie := newSessionCookie(t, h)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||
cookies = append(cookies, sessionCookie)
|
||||
|
||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||
sourceURLField: {testSourceURL},
|
||||
"format": {"jpeg"},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("within-limit POST /generate status = %d, want %d",
|
||||
rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||
t.Error("within-limit generate response did not contain a generated URL")
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ func (s *Handlers) initImageService() error {
|
||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||
}
|
||||
|
||||
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||
|
||||
// Create the service
|
||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||
Cache: cache,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpfetcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
||||
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
|
||||
// forms, alongside public controls that must stay reachable.
|
||||
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"cgnat-low", "100.64.0.1", true},
|
||||
{"cgnat-high", "100.127.255.254", true},
|
||||
{"ietf-protocol", "192.0.0.1", true},
|
||||
{"benchmark-low", "198.18.0.1", true},
|
||||
{"benchmark-high", "198.19.255.254", true},
|
||||
{"nat64", "64:ff9b::1", true},
|
||||
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
|
||||
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
|
||||
{"cloud-metadata", "169.254.169.254", true},
|
||||
{"public-v4", "8.8.8.8", false},
|
||||
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
|
||||
{"public-v6", "2001:4860:4860::8888", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||
}
|
||||
|
||||
got := isPrivateIP(ip)
|
||||
if got != tc.want {
|
||||
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// transportOf returns the *http.Transport backing a fetcher, so a test can
|
||||
// exercise the SSRF-safe dialer New installed with the operator blocklist.
|
||||
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
||||
t.Helper()
|
||||
|
||||
transport, ok := f.client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
|
||||
// blocked_networks entry is enforced by the dialer, in addition to the
|
||||
// built-in ranges, while an address outside both stays dialable.
|
||||
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
||||
// blocking it can only come from the operator-supplied list.
|
||||
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||
|
||||
transport := transportOf(t, New(cfg))
|
||||
|
||||
blocked := []string{
|
||||
"198.51.100.5:80", // operator-supplied range
|
||||
"10.0.0.5:80", // built-in RFC 1918, still enforced
|
||||
"100.64.0.1:80", // built-in CGNAT range
|
||||
}
|
||||
|
||||
for _, addr := range blocked {
|
||||
t.Run("blocked/"+addr, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := transport.DialContext(context.Background(), "tcp", addr)
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("public-not-blocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A cancelled context makes the dial fail without touching the
|
||||
// network; the point is only that a public literal outside every
|
||||
// blocked range is not SSRF-blocked.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
|
||||
if errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/netip"
|
||||
neturl "net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -46,6 +47,20 @@ const (
|
||||
localhostIPv6 = "::1"
|
||||
)
|
||||
|
||||
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
||||
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
||||
// already cover. They are always blocked, in addition to any
|
||||
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
|
||||
// before matching, so these IPv4 ranges are caught in both forms.
|
||||
//
|
||||
//nolint:gochecknoglobals // immutable built-in blocklist
|
||||
var builtinBlockedPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
||||
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
||||
}
|
||||
|
||||
// Fetcher errors.
|
||||
var (
|
||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||
@@ -107,6 +122,9 @@ type Config struct {
|
||||
AllowHTTP bool
|
||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||
MaxConnectionsPerHost int
|
||||
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||
// dialer, in addition to the always-enforced built-in ranges.
|
||||
BlockedNetworks []netip.Prefix
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults.
|
||||
@@ -142,9 +160,13 @@ func New(config *Config) *HTTPFetcher {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
// Create transport with SSRF-safe dialer
|
||||
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
||||
// re-checks at connect time (closing the DNS-rebinding window) against
|
||||
// both the built-in ranges and the operator-supplied blocklist.
|
||||
transport := &http.Transport{
|
||||
DialContext: ssrfSafeDialer,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
||||
},
|
||||
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||
MaxIdleConns: DefaultMaxIdleConns,
|
||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||
@@ -451,11 +473,53 @@ func isPrivateIP(ip net.IP) bool {
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
// Special-use ranges the net.IP predicates above do not cover.
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
|
||||
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
|
||||
return prefix.Contains(addr)
|
||||
})
|
||||
}
|
||||
|
||||
// ssrfSafeDialer is a custom dialer that validates IP addresses before connecting.
|
||||
// isBlockedIP reports whether ip is refused, either by the built-in
|
||||
// internal-range check or by one of the operator-supplied prefixes.
|
||||
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
||||
if isPrivateIP(ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
|
||||
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
||||
return prefix.Contains(addr)
|
||||
})
|
||||
}
|
||||
|
||||
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
||||
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
||||
// blocklist; this entry point enforces the built-in ranges alone.
|
||||
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialSSRFSafe(ctx, network, addr, nil)
|
||||
}
|
||||
|
||||
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
||||
// internal range or operator-supplied blocked prefix, closing the
|
||||
// DNS-rebinding window at connect time.
|
||||
func dialSSRFSafe(
|
||||
ctx context.Context,
|
||||
network, addr string,
|
||||
blocked []netip.Prefix,
|
||||
) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -468,8 +532,10 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Check all resolved IPs
|
||||
if slices.ContainsFunc(ips, isPrivateIP) {
|
||||
return nil, ErrSSRFBlocked
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip, blocked) {
|
||||
return nil, ErrSSRFBlocked
|
||||
}
|
||||
}
|
||||
|
||||
// Connect using the first valid IP
|
||||
|
||||
+27
-11
@@ -9,24 +9,40 @@ import (
|
||||
|
||||
// HTTP server configuration constants.
|
||||
const (
|
||||
HTTPReadTimeout = 30 * time.Second
|
||||
HTTPWriteTimeout = 60 * time.Second
|
||||
HTTPReadTimeout = 30 * time.Second
|
||||
// HTTPReadHeaderTimeout bounds the request-header read on its own,
|
||||
// short, so a slowloris client dribbling headers is dropped well
|
||||
// before it ties up a connection for the whole ReadTimeout window.
|
||||
HTTPReadHeaderTimeout = 10 * time.Second
|
||||
HTTPWriteTimeout = 60 * time.Second
|
||||
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
|
||||
// held open, so idle connections cannot accumulate without limit on a
|
||||
// service targeting high concurrency.
|
||||
HTTPIdleTimeout = 120 * time.Second
|
||||
HTTPMaxHeaderBytes = 8 << 10 // 8KB
|
||||
)
|
||||
|
||||
func (s *Server) serveUntilShutdown() {
|
||||
listenAddr := fmt.Sprintf(":%d", s.config.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: listenAddr,
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
WriteTimeout: HTTPWriteTimeout,
|
||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||
Handler: s,
|
||||
// newHTTPServer builds the http.Server with the hardening timeouts and
|
||||
// limits applied. It is separate from serveUntilShutdown so the
|
||||
// configuration can be asserted in a test without binding a listener.
|
||||
func (s *Server) newHTTPServer() *http.Server {
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.config.Port),
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
ReadHeaderTimeout: HTTPReadHeaderTimeout,
|
||||
WriteTimeout: HTTPWriteTimeout,
|
||||
IdleTimeout: HTTPIdleTimeout,
|
||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||
Handler: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveUntilShutdown() {
|
||||
s.httpServer = s.newHTTPServer()
|
||||
|
||||
s.SetupRoutes()
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// TestNewHTTPServerTimeouts verifies that the constructed http.Server
|
||||
// carries every hardening timeout wired onto it, including the slowloris
|
||||
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
|
||||
// guards against a field being defined but never set on the server, so
|
||||
// each assertion compares the server field to its constant.
|
||||
func TestNewHTTPServerTimeouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{config: &config.Config{Port: 8080}}
|
||||
|
||||
srv := s.newHTTPServer()
|
||||
|
||||
fields := []struct {
|
||||
name string
|
||||
got time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
|
||||
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
|
||||
{"WriteTimeout", srv.WriteTimeout, HTTPWriteTimeout},
|
||||
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if f.got != f.want {
|
||||
t.Errorf("%s = %v, want %v", f.name, f.got, f.want)
|
||||
}
|
||||
}
|
||||
|
||||
if srv.MaxHeaderBytes != HTTPMaxHeaderBytes {
|
||||
t.Errorf("MaxHeaderBytes = %d, want %d",
|
||||
srv.MaxHeaderBytes, HTTPMaxHeaderBytes)
|
||||
}
|
||||
|
||||
if srv.Handler != s {
|
||||
t.Error("Handler is not the server")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHardeningTimeoutValues pins the intent behind the two new timeouts
|
||||
// without hard-coding brittle exact durations: the header-read phase is
|
||||
// bounded strictly shorter than the whole-request read (the slowloris
|
||||
// dribble), and idle keep-alive connections are bounded rather than held
|
||||
// open forever.
|
||||
func TestHardeningTimeoutValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if HTTPReadHeaderTimeout <= 0 || HTTPReadHeaderTimeout > HTTPReadTimeout {
|
||||
t.Errorf("ReadHeaderTimeout = %v, want positive and <= ReadTimeout %v",
|
||||
HTTPReadHeaderTimeout, HTTPReadTimeout)
|
||||
}
|
||||
|
||||
if HTTPIdleTimeout <= 0 {
|
||||
t.Errorf("IdleTimeout = %v, want positive bound", HTTPIdleTimeout)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/handlers"
|
||||
"sneak.berlin/go/pixa/internal/static"
|
||||
)
|
||||
|
||||
@@ -46,8 +47,10 @@ func (s *Server) SetupRoutes() {
|
||||
|
||||
// Login/generator UI. The form routes carry CSRF protection; the
|
||||
// token cookie is independent of the session cookie, so it also
|
||||
// covers the login POST, where no session exists yet.
|
||||
// covers the login POST, where no session exists yet. LimitBody caps
|
||||
// the POST body ahead of CSRF, which reads its token from that body.
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||
r.Use(s.h.CSRF())
|
||||
r.Get("/", s.h.HandleRoot())
|
||||
r.Post("/", s.h.HandleRoot())
|
||||
|
||||
+4
-58
@@ -3,20 +3,13 @@
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||
# it is installed from a hash-verified GitHub release archive (never
|
||||
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are
|
||||
# installed for the govips bindings.
|
||||
# make, or go). The linter is never installed on the host: golangci-lint
|
||||
# runs only inside Dockerfile.lint (see script/lint). CGO image libraries
|
||||
# (pkg-config, vips, libheif) are installed for the govips bindings.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
||||
GOLANGCI_LINT_VERSION="2.12.2"
|
||||
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
|
||||
@@ -57,52 +50,6 @@ missing() {
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# verify_sha256 <file> <expected-hash>
|
||||
verify_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||
else
|
||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||
fi
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# apt has no golangci-lint package: install a pinned release archive
|
||||
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
||||
install_golangci_lint_release() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
||||
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
||||
*)
|
||||
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/$name.tar.gz" \
|
||||
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
||||
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
||||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
||||
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
ensure_golangci_lint() {
|
||||
if ! missing golangci-lint; then return 0; fi
|
||||
detect_pkgmgr
|
||||
case "$PKGMGR" in
|
||||
apt) install_golangci_lint_release ;;
|
||||
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# CGO dependencies for govips (image processing)
|
||||
ensure_cgo_deps() {
|
||||
if missing pkg-config; then
|
||||
@@ -123,9 +70,8 @@ main() {
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
|
||||
# Go toolchain and linter
|
||||
# Go toolchain
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
ensure_golangci_lint
|
||||
|
||||
# CGO image libraries
|
||||
ensure_cgo_deps
|
||||
|
||||
+46
-14
@@ -1,23 +1,55 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter. CGO dependencies (pkg-config, vips,
|
||||
# libheif) come from nix-shell when not already available (e.g. inside
|
||||
# a Docker build or an existing nix-shell).
|
||||
# script/lint: run golangci-lint over the whole tree.
|
||||
#
|
||||
# The linter is never installed on the host: it runs only inside the
|
||||
# Dockerfile.lint build, one way, everywhere. A clean build is a clean
|
||||
# lint. See Dockerfile.lint for why the lint step cannot be cached.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
run_with_cgo_deps() {
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
sh -c "$1"
|
||||
else
|
||||
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# A value no other run repeats. Dockerfile.lint folds it into the
|
||||
# lint step's cache key, so the linter re-executes every run instead
|
||||
# of an unchanged tree returning a cached success having linted
|
||||
# nothing.
|
||||
cachebust="$(date +%s)-$$"
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/pixa-lint.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
# --progress=plain so the lint step's own output reaches the log we
|
||||
# check below; --output=type=cacheonly because we want the linter's
|
||||
# verdict, not an image left in the local store. The build status
|
||||
# travels through a file: a pipeline's exit status is tee's, not the
|
||||
# build's.
|
||||
(
|
||||
set +e
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--build-arg CACHEBUST="$cachebust" \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint . 2>&1
|
||||
echo "$?" >"$tmp/status"
|
||||
) | tee "$tmp/build.log"
|
||||
|
||||
status="$(cat "$tmp/status" 2>/dev/null || echo 1)"
|
||||
[ "${status:-1}" -eq 0 ] || exit "${status:-1}"
|
||||
|
||||
# The linter's start line must appear as build output, not only in
|
||||
# the build's echo of the RUN instruction. A step served from cache
|
||||
# prints the instruction and none of its output; a step that runs
|
||||
# prints a "#<n> <elapsed> ..." output line. Requiring that output
|
||||
# line means a future edit dropping the CACHEBUST reference from
|
||||
# Dockerfile.lint fails here rather than passing having linted
|
||||
# nothing.
|
||||
if ! grep -Eq '^#[0-9]+ +[0-9]+\.[0-9]+ +pixa-lint: running golangci-lint' \
|
||||
"$tmp/build.log"; then
|
||||
echo "script/lint: golangci-lint did not execute (cached step?)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
echo "Running linter..."
|
||||
run_with_cgo_deps "golangci-lint run"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ run_with_cgo_deps() {
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
sh -c "$1"
|
||||
else
|
||||
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||
nix-shell -p pkg-config vips libheif git --run "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user