diff --git a/api/openapi.yaml b/api/openapi.yaml index 1badecb..aa91aed 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -298,6 +298,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/DatasetGroup" } "400": { $ref: "#/components/responses/BadRequest" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } get: @@ -460,6 +461,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/Dataset" } "400": { $ref: "#/components/responses/BadRequest" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } "422": { $ref: "#/components/responses/ValidationError" } /datasets/{id}: @@ -677,6 +679,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/Dataset" } "400": { $ref: "#/components/responses/BadRequest" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } get: @@ -823,6 +826,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/Dataset" } "400": { $ref: "#/components/responses/BadRequest" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } @@ -946,6 +950,11 @@ components: content: application/json: schema: { $ref: "#/components/schemas/Error" } + PayloadTooLarge: + description: Request body exceeds the configured upload limit (HTTP_MAX_UPLOAD_BYTES, default 2 GiB) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } ValidationError: description: Input failed validation content: diff --git a/build/package/Dockerfile b/build/package/Dockerfile index 55c6def..873860b 100644 --- a/build/package/Dockerfile +++ b/build/package/Dockerfile @@ -1,5 +1,8 @@ # Build context is the repo root: docker build -f build/package/Dockerfile . -FROM golang:1.26-alpine3.24 AS builder +# Go patch release pinned (not the floating 1.26 tag) so the shipped standard +# library is reproducible and includes the security fixes up to go1.26.8; +# keep in step with the `toolchain` line in go.mod. +FROM golang:1.26.8-alpine3.24 AS builder # godal (GDAL bindings) needs cgo, a C/C++ toolchain and the GDAL headers. # g++ is required: parts of the binding compile as C++ against GDAL's headers. @@ -14,7 +17,7 @@ COPY . . RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -o /out/gis ./cmd/gis # Must stay on the same Alpine release as the builder — hence the pinned -# `golang:1.26-alpine3.24` above rather than the floating `-alpine` tag. A +# `golang:1.26.8-alpine3.24` above rather than the floating `-alpine` tag. A # mismatch ships a different libgdal soname, and the image still builds green # while the binary dies at startup with "libgdal.so.NN: No such file". # Bump both lines together. diff --git a/configs/.env.example b/configs/.env.example index 9a4f79b..9568cd6 100644 --- a/configs/.env.example +++ b/configs/.env.example @@ -2,6 +2,9 @@ # HTTP server PORT=8080 +# Upper bound for any request body, uploads included (bytes; default 2 GiB). +# Larger requests are rejected with 413. 0 disables the cap. +HTTP_MAX_UPLOAD_BYTES=2147483648 # Postgres DB_URL=postgres://gis:gis@127.0.0.1:5432/gis?sslmode=disable diff --git a/deployments/docker-compose.yml b/deployments/docker-compose.yml index 5c97e31..115b9a1 100644 --- a/deployments/docker-compose.yml +++ b/deployments/docker-compose.yml @@ -1,16 +1,21 @@ +# Credentials below default to well-known development values so that +# `docker compose up` works out of the box. They MUST be overridden on any +# shared or reachable host: put strong values for POSTGRES_PASSWORD, +# MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, RABBITMQ_DEFAULT_USER and +# RABBITMQ_DEFAULT_PASS in deployments/.env (not committed). x-app-common: &app-common build: context: .. dockerfile: build/package/Dockerfile environment: &app-environment PORT: 8080 - DB_URL: postgres://gis:gis@postgres:5432/gis?sslmode=disable + DB_URL: postgres://gis:${POSTGRES_PASSWORD:-gis}@postgres:5432/gis?sslmode=disable S3_ENDPOINT: minio:9000 - S3_ACCESS_KEY: minioadmin - S3_SECRET_KEY: minioadmin + S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} S3_BUCKET: geofiles S3_USE_SSL: "false" - RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ + RABBITMQ_URL: amqp://${RABBITMQ_DEFAULT_USER:-guest}:${RABBITMQ_DEFAULT_PASS:-guest}@rabbitmq:5672/ RABBITMQ_EXCHANGE: gis.events RABBITMQ_QUEUE: gis.events.example depends_on: @@ -47,7 +52,7 @@ services: image: postgis/postgis:17-3.5 environment: POSTGRES_USER: gis - POSTGRES_PASSWORD: gis + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-gis} POSTGRES_DB: gis ports: - "${DB_PORT:-5432}:5432" @@ -67,8 +72,8 @@ services: - "9000:9000" - "9001:9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} volumes: - minio_data:/data healthcheck: @@ -84,8 +89,8 @@ services: - "5672:5672" - "15672:15672" environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:-guest} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:-guest} volumes: - rabbitmq_data:/var/lib/rabbitmq healthcheck: diff --git a/go.mod b/go.mod index e06f590..0e442f5 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module gis go 1.26.1 -toolchain go1.26.2 +toolchain go1.26.8 require ( github.com/airbusgeo/godal v0.0.18 @@ -14,10 +14,10 @@ require ( github.com/joho/godotenv v1.5.1 github.com/minio/minio-go/v7 v7.2.0 github.com/pressly/goose/v3 v3.27.1 - github.com/rabbitmq/amqp091-go v1.12.0 + github.com/rabbitmq/amqp091-go v1.13.0 github.com/spf13/cobra v1.10.2 - golang.org/x/sync v0.20.0 - golang.org/x/text v0.37.0 + golang.org/x/sync v0.22.0 + golang.org/x/text v0.41.0 modernc.org/sqlite v1.53.0 ) @@ -31,10 +31,9 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -46,13 +45,16 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect + github.com/stretchr/testify v1.12.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.45.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/ini.v1 v1.67.2 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f51c393..63f7c0b 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,58 @@ +cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= +cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= +cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= +cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= +cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= +cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/airbusgeo/errs v0.0.3 h1:+lUcbfm45tPLz/DMUC38QT+y9N60aKrPv4MXqVb7Noc= +github.com/airbusgeo/errs v0.0.3/go.mod h1:/U1fafLfbdxZXtIqdTGeGGzLqnZcnGJXsJ0S7TaP4P0= github.com/airbusgeo/godal v0.0.18 h1:NQJ9P7OgSs5zVtlWH/tOta7QHYbbtha7Nttf63YM01Q= github.com/airbusgeo/godal v0.0.18/go.mod h1:Mpn/6exBzEbIx4DaG2rIjCmAiF9Ww4SJN2ZtwVsSwCo= +github.com/airbusgeo/osio v0.1.3 h1:IxNf1co8lxwqJCziV+5qZyZbkT6JYdmR3L0oomNffrk= +github.com/airbusgeo/osio v0.1.3/go.mod h1:d4qlaDSgsMilFoi+/62vwFZStuYIHIDovYzIRjTK+Jw= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA= +github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -25,8 +63,16 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= +github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -41,17 +87,13 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -68,16 +110,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= -github.com/rabbitmq/amqp091-go v1.12.0 h1:V0v14Iqfs+MwHWihJt/nGS5Ulu0vw572b2Co3mwunkI= -github.com/rabbitmq/amqp091-go v1.12.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA= +github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -88,6 +129,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4= +github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -97,41 +140,74 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= +go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= +google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= diff --git a/internal/app/app.go b/internal/app/app.go index 3c8ea53..df4b023 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -108,15 +108,18 @@ func New(ctx context.Context) (*App, error) { // Handler builds the HTTP handler with all routes and readiness checks wired. func (a *App) Handler() stdhttp.Handler { validate := validator.New(validator.WithRequiredStructEnabled()) - validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool { + if err := validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool { return domain.ValidSlug(fl.Field().String()) - }) + }); err != nil { + // Only fails for an empty tag or nil func: a programming error. + panic(fmt.Sprintf("register slug validation: %v", err)) + } health := transporthttp.NewHealthHandler(map[string]transporthttp.ReadinessCheck{ "postgres": func(ctx context.Context) error { return a.pool.Ping(ctx) }, "s3": func(ctx context.Context) error { return a.store.Ping(ctx) }, "rabbitmq": func(_ context.Context) error { return a.rabbit.Ping() }, - }) + }, a.Log) return transporthttp.NewRouter(transporthttp.RouterDeps{ Logger: a.Log, @@ -126,6 +129,7 @@ func (a *App) Handler() stdhttp.Handler { DatasetGroups: transporthttp.NewDatasetGroupHandler(a.groups, validate), Landscape: transporthttp.NewLandscapeHandler(a.landscape, validate), OpenAPISpec: api.Spec, + MaxBodyBytes: a.Cfg.HTTP.MaxUploadBytes, }) } diff --git a/internal/config/config.go b/internal/config/config.go index a502098..89b7fbb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,9 @@ type HTTPConfig struct { WriteTimeout time.Duration `env:"HTTP_WRITE_TIMEOUT" envDefault:"120s"` IdleTimeout time.Duration `env:"HTTP_IDLE_TIMEOUT" envDefault:"60s"` ShutdownTimeout time.Duration `env:"HTTP_SHUTDOWN_TIMEOUT" envDefault:"10s"` + // MaxUploadBytes caps the size of any request body, multipart uploads + // included (default 2 GiB); larger requests get 413. 0 disables the cap. + MaxUploadBytes int64 `env:"HTTP_MAX_UPLOAD_BYTES" envDefault:"2147483648"` } // Addr returns the listen address for the HTTP server. diff --git a/internal/landscape/kernels.go b/internal/landscape/kernels.go index 833b83d..ad0aa41 100644 --- a/internal/landscape/kernels.go +++ b/internal/landscape/kernels.go @@ -156,12 +156,15 @@ func kernelMaxAnyValid(in [][]float64, out [][]int16, _ Params) { // S1..S5 (irrigation, snow, crop, pasture, tourism). Outputs: V1, V2, V3. func kernelFuncZoning(in [][]float64, out [][]int16, p Params) { e := in[0] + // The int16 conversions below cannot overflow (gosec G115): thresholds and + // tie_priority codes are limited to 1..5 by Params.Validate, and pixel + // values are converted only after valid() has bounded them to 1..5. rules := [3]func(e, s int16) bool{ func(e, s int16) bool { - return (e <= 2 && s >= int16(p.V1MinSE12)) || (e == 3 && s >= int16(p.V1MinSE3)) + return (e <= 2 && s >= int16(p.V1MinSE12)) || (e == 3 && s >= int16(p.V1MinSE3)) // #nosec G115 -- 1..5, see above }, func(e, s int16) bool { - return e <= 2 || (e == 3 && s >= int16(p.V2MinSE3)) || (e == 4 && s >= int16(p.V2MinSE4)) + return e <= 2 || (e == 3 && s >= int16(p.V2MinSE3)) || (e == 4 && s >= int16(p.V2MinSE4)) // #nosec G115 -- 1..5, see above }, func(e, s int16) bool { return e <= 4 }, } @@ -205,7 +208,7 @@ func kernelFuncZoning(in [][]float64, out [][]int16, p Params) { for _, code := range p.TiePriority { k := code - 1 if cand[k] && s[k] == maxS { - sector = int16(code) + sector = int16(code) // #nosec G115 -- tie_priority code is 1..5, see above break } } diff --git a/internal/parser/gpkg.go b/internal/parser/gpkg.go index 015ebd6..5703e9e 100644 --- a/internal/parser/gpkg.go +++ b/internal/parser/gpkg.go @@ -112,7 +112,9 @@ func gpkgScan(db *sql.DB, table string, names []string, limit int) []map[string] for i, n := range names { quoted[i] = quoteIdent(n) } - query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ", "), quoteIdent(table)) + // Identifiers cannot be bound as parameters; every one is quoted with + // quoteIdent (embedded quotes doubled) and the LIMIT is an int. + query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ", "), quoteIdent(table)) // #nosec G201 -- identifiers quoted by quoteIdent if limit >= 0 { query += fmt.Sprintf(" LIMIT %d", limit) } diff --git a/internal/raster/gdal.go b/internal/raster/gdal.go index 4806958..a2845bd 100644 --- a/internal/raster/gdal.go +++ b/internal/raster/gdal.go @@ -25,7 +25,8 @@ func NewGDALConverter() *GDALConverter { // ToCOG converts the source raster to a Cloud-Optimized GeoTIFF at dst. The COG // driver builds internal tiling and overviews. func (c *GDALConverter) ToCOG(ctx context.Context, src, dst string) error { - cmd := exec.CommandContext(ctx, "gdal_translate", + // Fixed binary, no shell; src/dst are service-generated temp-file paths. + cmd := exec.CommandContext(ctx, "gdal_translate", // #nosec G204 -- see above "-of", "COG", "-co", "COMPRESS="+c.compression, src, dst, @@ -41,7 +42,7 @@ func (c *GDALConverter) ToCOG(ctx context.Context, src, dst string) error { // Footprint returns the raster's footprint as a GeoJSON polygon in EPSG:4326, or // nil if the raster has no spatial reference. func (c *GDALConverter) Footprint(ctx context.Context, src string) ([]byte, error) { - out, err := exec.CommandContext(ctx, "gdalinfo", "-json", src).Output() + out, err := exec.CommandContext(ctx, "gdalinfo", "-json", src).Output() // #nosec G204 -- fixed binary, no shell; src is a service temp file if err != nil { return nil, fmt.Errorf("gdalinfo: %w", err) } @@ -72,7 +73,8 @@ func (c *GDALConverter) VectorFeatures(ctx context.Context, src string) ([]json. input = "/vsizip/" + src } - cmd := exec.CommandContext(ctx, "ogr2ogr", + // Fixed binary, no shell; input is a service-generated temp-file path. + cmd := exec.CommandContext(ctx, "ogr2ogr", // #nosec G204 -- see above "-f", "GeoJSON", "-t_srs", "EPSG:4326", "/vsistdout/", input, diff --git a/internal/service/dataset.go b/internal/service/dataset.go index d98c530..51e21d6 100644 --- a/internal/service/dataset.go +++ b/internal/service/dataset.go @@ -692,7 +692,7 @@ func (s *DatasetService) downloadToTemp(ctx context.Context, key, pattern string // uploadFile streams a local file to the object store. func (s *DatasetService) uploadFile(ctx context.Context, key, filePath, contentType string) error { - f, err := os.Open(filePath) + f, err := os.Open(filePath) // #nosec G304 -- filePath is a temp file this service created if err != nil { return err } diff --git a/internal/service/landscape.go b/internal/service/landscape.go index e6e1810..640e5ed 100644 --- a/internal/service/landscape.go +++ b/internal/service/landscape.go @@ -728,7 +728,7 @@ func (s *LandscapeService) download(ctx context.Context, key, path string) error } defer obj.Close() - f, err := os.Create(path) + f, err := os.Create(path) // #nosec G304 -- path is inside the node's os.MkdirTemp directory if err != nil { return err } diff --git a/internal/transport/http/content_disposition.go b/internal/transport/http/content_disposition.go new file mode 100644 index 0000000..f8112b3 --- /dev/null +++ b/internal/transport/http/content_disposition.go @@ -0,0 +1,63 @@ +package http + +import ( + "fmt" + "strings" +) + +// fallbackDownloadName is used when nothing printable survives sanitisation. +const fallbackDownloadName = "download" + +// contentDisposition builds a Content-Disposition header value for a stored, +// user-supplied filename following RFC 6266: an ASCII-only quoted fallback in +// filename= and the exact UTF-8 name, percent-encoded per RFC 8187, in +// filename*=. Neither parameter can carry quotes, backslashes, control +// characters (CR/LF) or parameter separators taken from the filename, so a +// crafted upload name cannot inject extra header parameters or headers. +func contentDisposition(disposition, filename string) string { + name := strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 // drop control characters, including CR and LF + } + return r + }, filename) + name = strings.TrimSpace(name) + if name == "" { + name = fallbackDownloadName + } + + ascii := strings.Map(func(r rune) rune { + if r > 0x7e || r == '"' || r == '\\' { + return '_' + } + return r + }, name) + + return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, ascii, encodeRFC8187(name)) +} + +// encodeRFC8187 percent-encodes every byte of s that is not an attr-char +// (RFC 8187 §3.2.1). +func encodeRFC8187(s string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if isAttrChar(c) { + b.WriteByte(c) + continue + } + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0x0f]) + } + return b.String() +} + +func isAttrChar(c byte) bool { + switch { + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': + return true + } + return strings.IndexByte("!#$&+-.^_`|~", c) >= 0 +} diff --git a/internal/transport/http/content_disposition_test.go b/internal/transport/http/content_disposition_test.go new file mode 100644 index 0000000..f7d73c5 --- /dev/null +++ b/internal/transport/http/content_disposition_test.go @@ -0,0 +1,57 @@ +package http + +import ( + "mime" + "strings" + "testing" +) + +func TestContentDisposition(t *testing.T) { + tests := []struct { + name string + filename string + want string + }{ + {"plain", "roads.geojson", `attachment; filename="roads.geojson"; filename*=UTF-8''roads.geojson`}, + {"space", "my map.tif", `attachment; filename="my map.tif"; filename*=UTF-8''my%20map.tif`}, + {"quote injection", `a.tif"; filename="evil.exe`, `attachment; filename="a.tif_; filename=_evil.exe"; filename*=UTF-8''a.tif%22%3B%20filename%3D%22evil.exe`}, + {"crlf injection", "a.tif\r\nSet-Cookie: x=1", `attachment; filename="a.tifSet-Cookie: x=1"; filename*=UTF-8''a.tifSet-Cookie%3A%20x%3D1`}, + {"backslash", `..\..\a.sld`, `attachment; filename=".._.._a.sld"; filename*=UTF-8''..%5C..%5Ca.sld`}, + {"cyrillic", "карта.tif", `attachment; filename="_____.tif"; filename*=UTF-8''%D0%BA%D0%B0%D1%80%D1%82%D0%B0.tif`}, + {"empty", "", `attachment; filename="download"; filename*=UTF-8''download`}, + {"only controls", "\r\n\t", `attachment; filename="download"; filename*=UTF-8''download`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := contentDisposition("attachment", tt.filename) + if got != tt.want { + t.Fatalf("contentDisposition(%q)\n got %s\nwant %s", tt.filename, got, tt.want) + } + if strings.ContainsAny(got, "\r\n") { + t.Fatalf("header value contains CR/LF: %q", got) + } + // The value must parse as a single disposition whose decoded + // filename is the sanitised original. + disp, params, err := mime.ParseMediaType(got) + if err != nil { + t.Fatalf("ParseMediaType(%q): %v", got, err) + } + if disp != "attachment" { + t.Fatalf("disposition = %q, want attachment", disp) + } + if len(params) != 1 { + t.Fatalf("params = %v, want exactly one decoded filename", params) + } + }) + } +} + +func TestContentDispositionDecodesUTF8Name(t *testing.T) { + _, params, err := mime.ParseMediaType(contentDisposition("inline", "карта 2026.cog.tif")) + if err != nil { + t.Fatal(err) + } + if got := params["filename"]; got != "карта 2026.cog.tif" { + t.Fatalf("decoded filename = %q", got) + } +} diff --git a/internal/transport/http/dataset_group_handler.go b/internal/transport/http/dataset_group_handler.go index 13970de..fdc923b 100644 --- a/internal/transport/http/dataset_group_handler.go +++ b/internal/transport/http/dataset_group_handler.go @@ -177,8 +177,7 @@ func (h *DatasetGroupHandler) uploadSLD(w http.ResponseWriter, r *http.Request) if !ok { return } - if err := r.ParseMultipartForm(maxUploadBytes); err != nil { - httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data") + if !parseMultipartForm(w, r) { return } @@ -228,7 +227,7 @@ func (h *DatasetGroupHandler) downloadSLD(w http.ResponseWriter, r *http.Request filename = *group.SldFilename } w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Content-Disposition", contentDisposition("attachment", filename)) if _, err := io.Copy(w, obj); err != nil { // Headers are already sent; nothing useful to return to the client. return diff --git a/internal/transport/http/dataset_handler.go b/internal/transport/http/dataset_handler.go index 402a210..f7e589e 100644 --- a/internal/transport/http/dataset_handler.go +++ b/internal/transport/http/dataset_handler.go @@ -18,9 +18,31 @@ import ( "github.com/google/uuid" ) -// maxUploadBytes caps the in-memory portion of a multipart upload (64 MiB). +// maxUploadBytes caps the in-memory portion of a multipart upload (64 MiB); +// larger parts spill to temporary files. The total request size is capped by +// the limitRequestBody middleware (HTTP_MAX_UPLOAD_BYTES). const maxUploadBytes = 64 << 20 +// parseMultipartForm parses a multipart upload and writes the error response +// itself when that fails: 413 when the body exceeds the router's size cap, 400 +// otherwise. It reports whether the handler may continue. +func parseMultipartForm(w http.ResponseWriter, r *http.Request) bool { + // #nosec G120 -- r.Body is wrapped in http.MaxBytesReader by the + // limitRequestBody middleware that NewRouter installs on every route. + err := r.ParseMultipartForm(maxUploadBytes) + if err == nil { + return true + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + httputil.WriteError(w, http.StatusRequestEntityTooLarge, + "request body exceeds the "+strconv.FormatInt(tooLarge.Limit, 10)+" byte limit") + return false + } + httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data") + return false +} + // DatasetHandler serves the /datasets routes. type DatasetHandler struct { svc *service.DatasetService @@ -121,8 +143,7 @@ func (h *DatasetHandler) mapping(w http.ResponseWriter, r *http.Request) { } func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(maxUploadBytes); err != nil { - httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data") + if !parseMultipartForm(w, r) { return } @@ -583,7 +604,7 @@ func (h *DatasetHandler) geojson(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/geo+json") w.WriteHeader(http.StatusOK) - _, _ = w.Write(fc) + _, _ = w.Write(fc) // #nosec G705 -- JSON body served as application/geo+json with nosniff, never as HTML } // cog streams the raster dataset's Cloud-Optimized GeoTIFF. It responds 422 for @@ -608,7 +629,7 @@ func (h *DatasetHandler) cog(w http.ResponseWriter, r *http.Request) { defer obj.Close() w.Header().Set("Content-Type", "image/tiff") - w.Header().Set("Content-Disposition", `inline; filename="`+cogFilename(dataset.Filename)+`"`) + w.Header().Set("Content-Disposition", contentDisposition("inline", cogFilename(dataset.Filename))) w.WriteHeader(http.StatusOK) if _, err := io.Copy(w, obj); err != nil { // Headers are already sent; nothing useful to return to the client. @@ -637,7 +658,7 @@ func (h *DatasetHandler) download(w http.ResponseWriter, r *http.Request) { defer obj.Close() w.Header().Set("Content-Type", dataset.ContentType) - w.Header().Set("Content-Disposition", `attachment; filename="`+dataset.Filename+`"`) + w.Header().Set("Content-Disposition", contentDisposition("attachment", dataset.Filename)) if dataset.SizeBytes > 0 { w.Header().Set("Content-Length", strconv.FormatInt(dataset.SizeBytes, 10)) } @@ -655,8 +676,7 @@ func (h *DatasetHandler) uploadSLD(w http.ResponseWriter, r *http.Request) { if !ok { return } - if err := r.ParseMultipartForm(maxUploadBytes); err != nil { - httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data") + if !parseMultipartForm(w, r) { return } @@ -708,7 +728,7 @@ func (h *DatasetHandler) downloadSLD(w http.ResponseWriter, r *http.Request) { filename = *dataset.SldFilename } w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Content-Disposition", contentDisposition("attachment", filename)) if _, err := io.Copy(w, obj); err != nil { // Headers are already sent; nothing useful to return to the client. return diff --git a/internal/transport/http/health.go b/internal/transport/http/health.go index b262f4c..d3f49a5 100644 --- a/internal/transport/http/health.go +++ b/internal/transport/http/health.go @@ -2,6 +2,7 @@ package http import ( "context" + "log/slog" "net/http" "gis/pkg/httputil" @@ -13,11 +14,17 @@ type ReadinessCheck func(ctx context.Context) error // HealthHandler serves liveness and readiness probes. type HealthHandler struct { checks map[string]ReadinessCheck + log *slog.Logger } // NewHealthHandler builds a HealthHandler with the given named readiness checks. -func NewHealthHandler(checks map[string]ReadinessCheck) *HealthHandler { - return &HealthHandler{checks: checks} +// Failed checks are logged with their error; the response only names the +// failing component, so connection details never reach the client. +func NewHealthHandler(checks map[string]ReadinessCheck, log *slog.Logger) *HealthHandler { + if log == nil { + log = slog.Default() + } + return &HealthHandler{checks: checks, log: log} } // Live reports that the process is up. @@ -32,7 +39,8 @@ func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) { ready := true for name, check := range h.checks { if err := check(r.Context()); err != nil { - results[name] = "error: " + err.Error() + h.log.Warn("readiness check failed", "component", name, "error", err) + results[name] = "unavailable" ready = false continue } diff --git a/internal/transport/http/landscape_handler.go b/internal/transport/http/landscape_handler.go index 846e597..7cef06a 100644 --- a/internal/transport/http/landscape_handler.go +++ b/internal/transport/http/landscape_handler.go @@ -58,8 +58,7 @@ func (h *LandscapeHandler) slots(w http.ResponseWriter, r *http.Request) { func (h *LandscapeHandler) uploadSlot(w http.ResponseWriter, r *http.Request) { code := chi.URLParam(r, "code") - if err := r.ParseMultipartForm(maxUploadBytes); err != nil { - httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data") + if !parseMultipartForm(w, r) { return } diff --git a/internal/transport/http/middleware.go b/internal/transport/http/middleware.go index 8445e99..790f26f 100644 --- a/internal/transport/http/middleware.go +++ b/internal/transport/http/middleware.go @@ -31,3 +31,29 @@ func requestLogger(log *slog.Logger) func(http.Handler) http.Handler { }) } } + +// limitRequestBody caps every request body at maxBytes (http.MaxBytesReader), +// so a client cannot stream an unbounded upload to memory or to the temporary +// files multipart parsing spills to. A non-positive maxBytes disables the cap. +// JSON endpoints apply their own, smaller cap in httputil.DecodeJSON. +func limitRequestBody(maxBytes int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if maxBytes <= 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + next.ServeHTTP(w, r) + }) + } +} + +// securityHeaders sets response headers that are safe for every route. +// nosniff stops browsers from MIME-sniffing user-supplied content (downloads, +// GeoJSON) into an executable type. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/transport/http/middleware_test.go b/internal/transport/http/middleware_test.go new file mode 100644 index 0000000..e13a5a5 --- /dev/null +++ b/internal/transport/http/middleware_test.go @@ -0,0 +1,131 @@ +package http + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-playground/validator/v10" +) + +// multipartBody builds a multipart/form-data body with one file part of +// size bytes. +func multipartBody(t *testing.T, size int) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", "big.tif") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte{'x'}, size)); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + return &buf, mw.FormDataContentType() +} + +func TestLimitRequestBodyRejectsOversizedUpload(t *testing.T) { + body, ct := multipartBody(t, 4096) + req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + + h := limitRequestBody(1024)(http.HandlerFunc(newLandscapeTestHandler().uploadSlot)) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "1024 byte limit") { + t.Fatalf("body %s does not state the limit", rec.Body.String()) + } +} + +func TestLimitRequestBodyPassesSmallBody(t *testing.T) { + var got []byte + h := limitRequestBody(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + got, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("read: %v", err) + } + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader("hello"))) + if string(got) != "hello" { + t.Fatalf("body = %q, want hello", got) + } +} + +func TestLimitRequestBodyDisabled(t *testing.T) { + h := limitRequestBody(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); err != nil { + t.Errorf("read with cap disabled: %v", err) + } + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(make([]byte, 1<<20)))) +} + +func newTestRouter() http.Handler { + v := validator.New(validator.WithRequiredStructEnabled()) + return NewRouter(RouterDeps{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Health: NewHealthHandler(nil, nil), + Categories: NewCategoryHandler(nil, v), + Datasets: NewDatasetHandler(nil, v), + DatasetGroups: NewDatasetGroupHandler(nil, v), + Landscape: NewLandscapeHandler(nil, v), + MaxBodyBytes: 1024, + }) +} + +func TestRouterSetsNosniff(t *testing.T) { + rec := httptest.NewRecorder() + newTestRouter().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } +} + +func TestRouterCapsUploadBody(t *testing.T) { + body, ct := multipartBody(t, 4096) + req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + newTestRouter().ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) + } +} + +func TestReadyDoesNotLeakCheckErrors(t *testing.T) { + h := NewHealthHandler(map[string]ReadinessCheck{ + "postgres": func(context.Context) error { + return errors.New("dial tcp 10.1.2.3:5432: connect: connection refused (user=gis)") + }, + "s3": func(context.Context) error { return nil }, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + rec := httptest.NewRecorder() + h.Ready(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + body := rec.Body.String() + if strings.Contains(body, "10.1.2.3") || strings.Contains(body, "dial tcp") { + t.Fatalf("readiness body leaks internal error details: %s", body) + } + if !strings.Contains(body, `"postgres":"unavailable"`) || !strings.Contains(body, `"s3":"ok"`) { + t.Fatalf("unexpected readiness body: %s", body) + } +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index af714d7..ca4ffd8 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -19,16 +19,23 @@ type RouterDeps struct { // Landscape serves the /landscape routes. Landscape *LandscapeHandler OpenAPISpec []byte + // MaxBodyBytes caps every request body (uploads included); <= 0 disables + // the cap. + MaxBodyBytes int64 } // NewRouter builds the application's HTTP handler with middleware and routes. func NewRouter(deps RouterDeps) http.Handler { r := chi.NewRouter() + // middleware.RealIP is deliberately not used: it rewrites r.RemoteAddr + // from client-controlled headers (X-Forwarded-For, X-Real-IP, + // True-Client-IP) and is deprecated upstream as an IP-spoofing vector. r.Use(middleware.RequestID) - r.Use(middleware.RealIP) r.Use(requestLogger(deps.Logger)) r.Use(middleware.Recoverer) + r.Use(securityHeaders) + r.Use(limitRequestBody(deps.MaxBodyBytes)) // Health/readiness endpoints. r.Get("/healthz", deps.Health.Live)