Compare commits

...
2 Commits
Author SHA1 Message Date
clawbot 37b21e1de8 Re-vendor the canonical files from sneak/prompts at dd4027b (closes #65)
check / check (push) Successful in 4m26s
The vendored files are fetched from sneak/prompts commit dd4027b. This
repository's own entries are kept after the canonical content: /bin in
.dockerignore, the Go lines of .gitignore and [*.go] in .editorconfig;
the test-support deny list has no entries of its own. The lint phase
moves to golangci-lint v2.14.0. The build stage now takes the version
from git describe on the .git the build context carries, unless VERSION
is passed, and fails when .git is present but no version comes out. The
test phase drops -count=1, which the policy says it does not need, and
keeps its tmpfs build cache. One test calls Header.Get with X-Real-IP,
as canonicalheader asks.

Model: opus-5-5
2026-10-06 00:45:29 +00:00
clawbot 7f6f89cd83 Network lists: always allowed, exempt from rate limits, always refused (closes #19)
check / check (push) Successful in 3m47s
Adds SWWAF_ALLOW_NETS, SWWAF_RATE_LIMIT_EXEMPT_NETS and SWWAF_DENY_NETS,
read like SWWAF_TRUSTED_PROXIES and empty by default, and checked against
the client's own address before its country is looked up. A client in
SWWAF_ALLOW_NETS skips the country lists and the rate limits and is not
looked up. One in SWWAF_DENY_NETS is refused with 403, logged as denied
and not counted. One in SWWAF_RATE_LIMIT_EXEMPT_NETS is neither counted
nor refused by the rate limits. SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES now
refuses a private, loopback or link-local client unless SWWAF_ALLOW_NETS
lists it.

Judgement call: an address in both SWWAF_ALLOW_NETS and SWWAF_DENY_NETS is let through.
Judgement call: the size and time limits still apply to SWWAF_ALLOW_NETS.

Model: opus-5-5
2026-10-06 02:36:27 +02:00
18 changed files with 541 additions and 124 deletions
+17 -3
View File
@@ -13,9 +13,21 @@
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context. # deletes the package directory from the context.
# Excluding .git means `git describe` cannot run in any build stage and # .git is sent without its config. Without a VERSION build argument the
# fails quietly there; pass the version in with --build-arg VERSION. # stage that compiles runs `git describe --tags --always` on .git, which
.git # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent. # Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root. # Anchored because it occurs once where agents run at the repo root.
@@ -39,7 +51,9 @@
**/[iI][dD]_[rR][sS][aA] **/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA] **/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA] **/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519 **/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in. # Dependencies: restored inside the image, never copied in.
**/node_modules **/node_modules
+25 -5
View File
@@ -20,11 +20,31 @@ Thumbs.db
# Node # Node
node_modules/ node_modules/
# Environment / secrets # Secrets. Unanchored like every entry above, so each matches at every
.env # depth. Matching is case-sensitive on Linux, so names use character
.env.* # ranges rather than a lowercase form that misses `Server.Key`.
*.pem
*.key # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# Go: the binary `make build` writes, test binaries, profiles and logs # Go: the binary `make build` writes, test binaries, profiles and logs
/bin/ /bin/
+1
View File
@@ -17,6 +17,7 @@ linters:
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
+23 -10
View File
@@ -2,8 +2,8 @@
# lint` or `script/lint`, which are themselves a docker build and would # lint` or `script/lint`, which are themselves a docker build and would
# recurse into a daemon that does not exist in a build step. # recurse into a daemon that does not exist in a build step.
# #
# golangci/golangci-lint v2.12.2 (built with go1.26.2), 2026-05-06 # golangci/golangci-lint v2.14.0 (built with go1.27.0), 2026-09-24
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
@@ -32,9 +32,9 @@ COPY . .
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it # Go's build cache is kept on a tmpfs, out of the image: nothing uses it
# after this step, and writing it into the image takes seconds. # after this step, and writing it into the image takes seconds.
RUN --mount=type=tmpfs,target=/root/.cache/go-build \ RUN --mount=type=tmpfs,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -cover ./... || \ go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from the two phases above; the copies # Build stage. Nothing is wanted from the two phases above; the copies
# are what make BuildKit build them first, so the image, which needs this # are what make BuildKit build them first, so the image, which needs this
@@ -46,6 +46,10 @@ FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
# This image has git. A tar-stream context keeps the sender's file
# owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -53,12 +57,21 @@ RUN go mod download
COPY . . COPY . .
# The version is computed on the host and passed in, because # The VERSION build arg when one is given, otherwise
# .dockerignore excludes .git. # `git describe --tags --always` on the .git in the build context. With
ARG VERSION=dev # .git present, a version that is still empty, dev or unknown fails the
RUN CGO_ENABLED=0 go build -trimpath \ # build: git is missing or could not read the checkout.
-ldflags="-s -w -X main.Version=${VERSION}" \ ARG VERSION
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
# runsvinit, the image's entrypoint, built at the last commit of its # runsvinit, the image's entrypoint, built at the last commit of its
# archived repository. It has no go.mod, and `go build` of its directory # archived repository. It has no go.mod, and `go build` of its directory
+53 -36
View File
@@ -13,14 +13,15 @@ JSON log line for every request.
Status: the first two milestones are built Status: the first two milestones are built
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and (https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
https://git.eeqj.de/sneak/smallwebwaf/issues/14). `smallwebwaf` passes each https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are the static lists,
request to the app and the app's answer back, unchanged, within its timeouts and which come next in the build order. `smallwebwaf` passes each request to the app
size limits, works out each client's address, refuses a client that sends too and the app's answer back, unchanged, within its timeouts and size limits, works
many requests or comes from a country you refuse, and writes a JSON log line for out each client's address, refuses a client that sends too many requests, comes
every request. It comes as the image the app's own image is built on. The rest from a country you refuse or from a network you refuse, lets the networks you
of the design comes after that, in the order of the build order in choose through, and writes a JSON log line for every request. It comes as the
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in image the app's own image is built on. The rest of the design comes after that,
[`EVALUATION.md`](EVALUATION.md). in the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
## Getting started ## Getting started
@@ -82,8 +83,17 @@ and `make run` builds and runs it, listening on port 8080 in front of an app at
counted for the rate limits. While one of the country lists below is set, each counted for the rate limits. While one of the country lists below is set, each
client's country is looked up through GeoJS (see "Country and AS number client's country is looked up through GeoJS (see "Country and AS number
lookup" below); with neither set, no visitor's address leaves the host. A lookup" below); with neither set, no visitor's address leaves the host. A
client on a private, loopback or link-local address has no country, and client on a private, loopback or link-local address has no country and is
neither list checks it. never looked up: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless it is
in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
- Checks the client's own address against the static lists, the three netblock
settings below, before anything else, its country included. A client in
`SWWAF_ALLOW_NETS` skips the country lists and the rate limits, and is not
looked up; the timeouts and size limits still apply. A client in
`SWWAF_DENY_NETS` is refused with `403` before its body is read, and the
request is not counted for the rate limits; an address in `SWWAF_ALLOW_NETS`
too is let through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither
counted nor refused by the rate limits; the country lists still apply to it.
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any - Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
check and without asking the app, for the image's health check. check and without asking the app, for the image's health check.
- Writes a line in the request log for each request (see "Request log" below). - Writes a line in the request log for each request (see "Request log" below).
@@ -111,6 +121,11 @@ it, and the effective settings are logged at start.
to send its whole answer, from the end of the request to the last byte. to send its whole answer, from the end of the request to the last byte.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. - `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. - `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip the country
lists and the rate limits, such as your monitoring or your own networks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
rate limits do not apply to, such as a machine that talks to the app all day.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR` - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most (default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
requests a client may make in a minute, an hour and a day. The defaults are requests a client may make in a minute, an hour and a day. The defaults are
@@ -153,18 +168,19 @@ refused ones included:
- `time` is when the request arrived, in UTC. `peer_ip` is the TCP peer, - `time` is when the request arrived, in UTC. `peer_ip` is the TCP peer,
normally traefik. `path` and `query` are as the client sent them. normally traefik. `path` and `query` are as the client sent them.
- `country` is the client's country as GeoJS places it, and empty when it is not - `country` is the client's country as GeoJS places it, and empty when it is not
known: with neither country list set, for a client on a private, loopback or known: with neither country list set, for a client in `SWWAF_ALLOW_NETS` or
link-local address, and when GeoJS cannot place the client or has not answered `SWWAF_DENY_NETS`, for a client on a private, loopback or link-local address,
in time. and when GeoJS cannot place the client or has not answered in time.
- `status` is what the client was sent, `0` if nothing was; `upstream_status` is - `status` is what the client was sent, `0` if nothing was; `upstream_status` is
what the app answered, and is left out when the app did not answer. what the app answered, and is left out when the app did not answer.
- `request_bytes` and `response_bytes` count body bytes. - `request_bytes` and `response_bytes` count body bytes.
- `action` is `forward` for a request passed to the app, `country_denied` for - `action` is `forward` for a request passed to the app, `denied` for one
one refused for its client's country, `rate_limited` for one refused for a refused because its client is in `SWWAF_DENY_NETS`, `country_denied` for one
rate limit, `too_large` for a request or response over its size limit, refused for its client's country, `rate_limited` for one refused for a rate
`timed_out` for one that ran out of time, `upstream_error` when the app could limit, `too_large` for a request or response over its size limit, `timed_out`
not be reached or its answer broke off, and `admin` for one `smallwebwaf` for one that ran out of time, `upstream_error` when the app could not be
answered at its own endpoint. reached or its answer broke off, and `admin` for one `smallwebwaf` answered at
its own endpoint.
- `limit_hit` is there for a request refused for a rate limit, and names the - `limit_hit` is there for a request refused for a rate limit, and names the
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
went over several. went over several.
@@ -402,16 +418,17 @@ the metrics, failure behaviour and the build order.
So far `smallwebwaf` looks up only the country, only through GeoJS, and only So far `smallwebwaf` looks up only the country, only through GeoJS, and only
while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set: while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set:
then the address of every new visitor is sent to GeoJS, and with neither set, then the address of every new visitor outside `SWWAF_ALLOW_NETS` and
none is. An IPv6 visitor is asked about by the first address of its /64. A new `SWWAF_DENY_NETS` is sent to GeoJS, and with neither set, none is. An IPv6
visitor waits at most a second for its answer, and without one counts as coming visitor is asked about by the first address of its /64. A new visitor waits at
from an unknown country until the answer arrives. The addresses waiting are most a second for its answer, and without one counts as coming from an unknown
asked about together, up to 200 in one request, one request at a time; at most country until the answer arrives. The addresses waiting are asked about
10,000 visitors wait, and one more counts as coming from an unknown country together, up to 200 in one request, one request at a time; at most 10,000
until there is room. While GeoJS fails, visitors with a kept answer are visitors wait, and one more counts as coming from an unknown country until there
unaffected and new ones count as coming from an unknown country. GeoJS is then is room. While GeoJS fails, visitors with a kept answer are unaffected and new
left alone for a second, twice as long after each further failure up to five ones count as coming from an unknown country. GeoJS is then left alone for a
minutes, and asked again by the next request that needs it. second, twice as long after each further failure up to five minutes, and asked
again by the next request that needs it.
In the full design, `smallwebwaf` looks up the AS number and country of every In the full design, `smallwebwaf` looks up the AS number and country of every
client, for the request log, the metrics and the ban notes, and for the country client, for the request log, the metrics and the ban notes, and for the country
@@ -447,9 +464,8 @@ data is powered by IPinfo". A service that uses the database through
Neither source can place a private address, so a client on one, such as a Neither source can place a private address, so a client on one, such as a
visitor on your local network, another container or your monitoring, has no visitor on your local network, another container or your monitoring, has no
country: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in country: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in
`SWWAF_ALLOW_NETS`. Such addresses are never sent to GeoJS. In milestone 2, `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it. Such
which has no `SWWAF_ALLOW_NETS`, neither country list checks such a client; the addresses are never sent to GeoJS.
refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
## How the code is laid out ## How the code is laid out
@@ -462,8 +478,9 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
the checks, passes the request to the app and the answer back with the the checks, passes the request to the app and the answer back with the
standard library's `httputil.ReverseProxy` within the timeouts and size standard library's `httputil.ReverseProxy` within the timeouts and size
limits, and writes the request's log line. Its `check` method is where a limits, and writes the request's log line. Its `check` method is where a
request is refused before anything reaches the app: for the country lists, for request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
a rate limit, and for an announced body over the size limit. the country lists, for a rate limit, and for an announced body over the size
limit.
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the - `internal/lookup`: looks up each client's country through GeoJS, and keeps the
answers. answers.
- `internal/ratelimit`: counts each client's requests and tells when one takes - `internal/ratelimit`: counts each client's requests and tells when one takes
@@ -518,8 +535,8 @@ so that they run in minimal containers.
## TODO ## TODO
- Milestone 3 and the rest of the design, in the order of the build order in - The rest of milestone 3, after the static lists, and the rest of the design,
[`SPEC.md`](SPEC.md). in the order of the build order in [`SPEC.md`](SPEC.md).
## Documents ## Documents
+120 -44
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-09-08 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -104,10 +104,14 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the `lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image. brings up a development environment; for server repos it is the runtime image.
Dockerfiles install development prerequisites by running `script/bootstrap` The gate phases and the build stage start from their pinned base images and
rather than duplicating installs inline; COPY `script/` and the dependency install what those images lack either inline, as the canonical Go `Dockerfile`
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before below does for `git`, or by running `script/bootstrap`, as the `prompts`
running it. repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase no separate lint file. `script/lint` and `script/test` each build one phase
@@ -156,11 +160,14 @@ style conventions are in separate documents:
not evidence that anything ran: a sub-second build reporting success is a not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune` cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host. and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile, hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Go image. The canonical Go repo and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
@@ -173,8 +180,9 @@ style conventions are in separate documents:
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.x-alpine, YYYY-MM-DD # image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -191,15 +199,29 @@ style conventions are in separate documents:
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG VERSION=dev # The VERSION build arg when one is given, otherwise
RUN CGO_ENABLED=0 go build -trimpath \ # `git describe --tags --always` on the .git in the build context. With
-ldflags="-s -w -X main.Version=${VERSION}" \ # .git present, a version that is still empty, dev or unknown fails the
-o /app ./cmd/app/ # build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
@@ -221,10 +243,41 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting (e.g. - If the project requires CGO or system libraries for linting, install them
`vips-dev`), install them in the lint phase with `apk add`. in the lint phase. The `golangci/golangci-lint` image is Debian-based and
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by has no `apk`, so install with `apt-get` under the Debian package name
`script/docker` and `script/cibuild`; no stage may call `git describe`. (`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -233,7 +286,12 @@ style conventions are in separate documents:
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -286,17 +344,19 @@ style conventions are in separate documents:
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_ `-count=1` is required on both invocations: it defeats Go's test _result_
cache, so the target cannot report a pass it did not earn, and the rerun cache, so neither run can report a stored pass in place of running the
reproduces a failure instead of replaying it. It leaves the build cache tests. It leaves the build cache alone, so it costs the runtime of the suite
alone, so it costs the runtime of the suite and no recompilation. and no recompilation.
Note that this is a second, independent cache, stacked below the Docker That cache is Go's own, separate from Docker's layer cache. Go stores a
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) passing result in its cache directory (`GOCACHE`), and when the same tests
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; run again on unchanged code it prints that result, marked `(cached)`,
it does not guarantee `go test` inside that step does any work, because the without running them. That matters on a developer's machine, where this
`GOCACHE` baked into earlier image layers survives into the re-executed target runs and the directory lasts from one run to the next. The `test`
step. They are two separate defects requiring two separate fixes, and a fix phase of the `Dockerfile` needs no `-count=1`: its base image holds no
for one must not be recorded as covering the other. result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -340,7 +400,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all, — which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.git`, and the repo's own host-built root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is `cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -365,12 +425,13 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there. `services/api/.claude/` and must add its own anchored entry there.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and - **A plain `docker build .` of a clone stamps the version that
it fails quietly there.** In a build stage there is no repository, so `git describe --tags --always` gives**, derived from the `.git` in the build
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty, context as the canonical `Dockerfile` above shows. Without its failure check,
the binary reports no version at all, and the build still exits 0. Compute the a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
version on the host and thread it in as a build arg. `script/docker` and and the build would still exit 0. `script/docker` and `script/cibuild` pass
`script/cibuild` do this, byte-identically across repos: the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh ```sh
# Own line: a failing command substitution inside an argument does not # Own line: a failing command substitution inside an argument does not
@@ -387,7 +448,7 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
@@ -426,12 +487,18 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
only pin, since no repo installs golangci-lint on the host: bumping the directive must not name a newer Go minor version than the one golangci-lint
version means changing it and nothing else. was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -455,6 +522,11 @@ style conventions are in separate documents:
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -567,10 +639,10 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
language-specific config). Everything else goes in a subdirectory. Canonical and language-specific config). Everything else goes in a subdirectory.
subdirectory names: Canonical subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in body is a single call into `internal/` or `pkg/`, no project logic in
@@ -601,3 +673,7 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+11
View File
@@ -45,6 +45,14 @@ type Config struct {
// ResponseMaxBytes is the largest response body // ResponseMaxBytes is the largest response body
// (SWWAF_RESPONSE_MAX_BYTES). // (SWWAF_RESPONSE_MAX_BYTES).
ResponseMaxBytes int64 ResponseMaxBytes int64
// AllowNets are the netblocks whose clients skip every check
// (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
// rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS).
// DenyNets are those whose clients are always refused
// (SWWAF_DENY_NETS).
AllowNets []netip.Prefix
RateLimitExemptNets []netip.Prefix
DenyNets []netip.Prefix
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the // RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
// most requests a client may make in a minute, an hour and a day // most requests a client may make in a minute, an hour and a day
// (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and // (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and
@@ -112,6 +120,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"), UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"), RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"), ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
+18
View File
@@ -25,6 +25,9 @@ const (
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT" upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES" responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
allowNets = "SWWAF_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR" rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
@@ -81,6 +84,9 @@ func TestDefaults(t *testing.T) {
wantNetblocks(t, cfg.TrustedProxies, wantNetblocks(t, cfg.TrustedProxies,
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets)
wantCountries(t, deniedCountries, cfg.DeniedCountries) wantCountries(t, deniedCountries, cfg.DeniedCountries)
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries) wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
} }
@@ -98,6 +104,9 @@ func TestValuesAsSet(t *testing.T) {
upstreamResponseTimeout: off, upstreamResponseTimeout: off,
requestMaxBytes: "512K", requestMaxBytes: "512K",
responseMaxBytes: "1234", responseMaxBytes: "1234",
allowNets: "192.0.2.7",
rateLimitExemptNets: "2001:db8::/48, 10.9.8.7",
denyNets: "198.51.100.0/24",
rateLimitPerMinute: "60", rateLimitPerMinute: "60",
rateLimitPerHour: "600", rateLimitPerHour: "600",
rateLimitPerDay: "6000", rateLimitPerDay: "6000",
@@ -123,6 +132,9 @@ func TestValuesAsSet(t *testing.T) {
} }
wantNetblocks(t, cfg.TrustedProxies, "192.0.2.1/32", "10.0.0.0/8", "2001:db8::/32") wantNetblocks(t, cfg.TrustedProxies, "192.0.2.1/32", "10.0.0.0/8", "2001:db8::/32")
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK") wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE") wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
} }
@@ -205,6 +217,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{trustedProxies, "traefik"}, {trustedProxies, "traefik"},
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"}, {trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
{trustedProxies, "fe80::1%eth0"}, {trustedProxies, "fe80::1%eth0"},
{allowNets, "192.0.2.0/24,monitoring"},
{rateLimitExemptNets, "2001:db8::/129"},
{denyNets, "198.51.100.0/24,"},
{clientRequestTimeout, "60"}, {clientRequestTimeout, "60"},
{clientRequestTimeout, ""}, {clientRequestTimeout, ""},
{clientResponseTimeout, "1y"}, {clientResponseTimeout, "1y"},
@@ -279,6 +294,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamResponseTimeout: "30m", upstreamResponseTimeout: "30m",
requestMaxBytes: "100M", requestMaxBytes: "100M",
responseMaxBytes: "5G", responseMaxBytes: "5G",
allowNets: "",
rateLimitExemptNets: "",
denyNets: "",
rateLimitPerMinute: "1000", rateLimitPerMinute: "1000",
rateLimitPerHour: "10000", rateLimitPerHour: "10000",
rateLimitPerDay: "50000", rateLimitPerDay: "50000",
+1 -1
View File
@@ -139,7 +139,7 @@ func requestWithHeaders(
ForwardedFor: r.Header.Get(forwardedFor), ForwardedFor: r.Header.Get(forwardedFor),
ForwardedHost: r.Header.Get("X-Forwarded-Host"), ForwardedHost: r.Header.Get("X-Forwarded-Host"),
ForwardedProto: r.Header.Get("X-Forwarded-Proto"), ForwardedProto: r.Header.Get("X-Forwarded-Proto"),
RealIP: r.Header.Get("X-Real-Ip"), RealIP: r.Header.Get("X-Real-IP"),
}) })
}) })
addr, out := startProxy(t, app.URL, env) addr, out := startProxy(t, app.URL, env)
+6 -6
View File
@@ -9,9 +9,9 @@ import (
// countryDenied reports whether the country lists refuse the request. // countryDenied reports whether the country lists refuse the request.
// The client's country is looked up only while a list is set, and never // The client's country is looked up only while a list is set, and never
// for a client on a private, loopback or link-local address, which has // for a client on a private, loopback or link-local address, which has
// no country and which neither list checks. A client whose country // no country. A client without a country, or whose country cannot be
// cannot be found is refused only by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. // found, is refused only by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. ctx is
// ctx is the request's own context. // the request's own context.
func (rq *request) countryDenied(ctx context.Context) bool { func (rq *request) countryDenied(ctx context.Context) bool {
denied := rq.h.config.DeniedCountries denied := rq.h.config.DeniedCountries
allowed := rq.h.config.ExclusivelyAllowedCountries allowed := rq.h.config.ExclusivelyAllowedCountries
@@ -20,11 +20,11 @@ func (rq *request) countryDenied(ctx context.Context) bool {
return false return false
} }
if !hasCountry(rq.client) { var country string
return false if hasCountry(rq.client) {
country = rq.h.geojs.Country(ctx, clientGroup(rq.client))
} }
country := rq.h.geojs.Country(ctx, clientGroup(rq.client))
rq.line.Country = country rq.line.Country = country
if slices.Contains(denied, country) { if slices.Contains(denied, country) {
+42 -1
View File
@@ -185,7 +185,7 @@ func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
{"no country list is set", nil, []string{fromKP, fromDE}}, {"no country list is set", nil, []string{fromKP, fromDE}},
{ {
"private, loopback and link-local addresses", "private, loopback and link-local addresses",
map[string]string{allowedCountries: "de"}, map[string]string{deniedCountries: "kp"},
[]string{"10.0.0.5", "192.168.1.9", "fd00::5", "", "169.254.0.9", "fe80::9"}, []string{"10.0.0.5", "192.168.1.9", "fd00::5", "", "169.254.0.9", "fe80::9"},
}, },
} { } {
@@ -223,6 +223,47 @@ func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
} }
} }
func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
allowNets string
status int
action string
}{
{
"not in SWWAF_ALLOW_NETS", "",
http.StatusForbidden, requestlog.ActionCountryDenied,
},
{
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
http.StatusOK, requestlog.ActionForward,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, asked := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
allowedCountries: "de",
allowNets: tc.allowNets,
})
wantAnswers(t, addr, out, []sentRequest{
{"10.0.0.7", tc.status, tc.action},
{"fd00::5", tc.status, tc.action},
})
if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked())
}
})
}
}
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP // startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP
// and no other address. It returns its URL, and what returns the // and no other address. It returns its URL, and what returns the
// addresses it has been asked about. // addresses it has been asked about.
+3
View File
@@ -51,6 +51,9 @@ const (
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES" responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
trustedProxies = "SWWAF_TRUSTED_PROXIES" trustedProxies = "SWWAF_TRUSTED_PROXIES"
allowNets = "SWWAF_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
deniedCountries = "SWWAF_DENIED_COUNTRIES" deniedCountries = "SWWAF_DENIED_COUNTRIES"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
+27 -12
View File
@@ -103,29 +103,44 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
// check is the one place where a request can be refused once its client // check is the one place where a request can be refused once its client
// is known, before its body is read or anything reaches the app. It // is known, before its body is read or anything reaches the app. It
// returns nil to let the request through. The country lists come first, // returns nil to let the request through. A client in SWWAF_ALLOW_NETS
// and a request they refuse is not counted for the rate limits; then the // skips every check but the size limit. For any other client,
// rate limits, so that every other request is counted, one refused for // SWWAF_DENY_NETS comes first, so that a client it refuses is not looked
// its size too. ctx is the request's own context. // up, and then the country lists; a request either refuses is not counted
// for the rate limits. Then come the rate limits, unless the client is in
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted,
// one refused for its size too. ctx is the request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
if rq.countryDenied(ctx) { cfg := rq.h.config
allowed := isInside(rq.client, cfg.AllowNets)
if !allowed && isInside(rq.client, cfg.DenyNets) {
return &refusal{
status: http.StatusForbidden,
action: requestlog.ActionDenied,
}
}
if !allowed && rq.countryDenied(ctx) {
return &refusal{ return &refusal{
status: http.StatusForbidden, status: http.StatusForbidden,
action: requestlog.ActionCountryDenied, action: requestlog.ActionCountryDenied,
} }
} }
limitHit := rq.h.limiter.Count(clientGroup(rq.client), rq.start) if !allowed && !isInside(rq.client, cfg.RateLimitExemptNets) {
if limitHit != "" { limitHit := rq.h.limiter.Count(clientGroup(rq.client), rq.start)
rq.line.LimitHit = limitHit if limitHit != "" {
rq.line.LimitHit = limitHit
return &refusal{ return &refusal{
status: http.StatusTooManyRequests, status: http.StatusTooManyRequests,
action: requestlog.ActionRateLimited, action: requestlog.ActionRateLimited,
}
} }
} }
maxBytes := rq.h.config.RequestMaxBytes maxBytes := cfg.RequestMaxBytes
if maxBytes > 0 && rq.in.ContentLength > maxBytes { if maxBytes > 0 && rq.in.ContentLength > maxBytes {
return &refusal{ return &refusal{
status: http.StatusRequestEntityTooLarge, status: http.StatusRequestEntityTooLarge,
+184
View File
@@ -0,0 +1,184 @@
package proxy_test
import (
"net/http"
"strings"
"sync/atomic"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The rate limits count an IPv6 client by its /64, so these two addresses
// are one client for them. The static lists match each address on its own,
// and the tests list listedAddr alone.
const (
listedAddr = "2001:db8::1"
unlistedAddr = "2001:db8::2"
)
func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
t.Parallel()
var calls atomic.Int32
app := startApp(t, func(http.ResponseWriter, *http.Request) {
calls.Add(1)
})
geojsURL, asked := startGeoJS(t)
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which
// comes after it.
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
allowNets: "198.51.100.0/24",
denyNets: fromKP,
deniedCountries: "kp",
rateLimitPerMinute: "1",
requestMaxBytes: "1K",
})
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one
// request a minute refuses the client, and its country is not looked
// up.
wantAnswers(t, addr, out, []sentRequest{
{fromKP, http.StatusOK, requestlog.ActionForward},
{fromKP, http.StatusOK, requestlog.ActionForward},
})
if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked())
}
// The size limit still applies.
body := strings.NewReader(strings.Repeat("a", 2<<10))
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set(forwardedFor, fromKP)
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
wantLine(t, out.requestLines(t, 3)[2],
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
if calls.Load() != 2 {
t.Errorf("the app was called %d times, want 2", calls.Load())
}
}
func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, map[string]string{
trustedProxies: trustLocalhost,
allowNets: listedAddr,
rateLimitPerMinute: "1",
})
// listedAddr's requests are not counted, so the first request from
// unlistedAddr is within the limit of one a minute.
wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusTooManyRequests, requestlog.ActionRateLimited},
})
}
func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
t.Parallel()
var calls atomic.Int32
app := startApp(t, func(http.ResponseWriter, *http.Request) {
calls.Add(1)
})
geojsURL, asked := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24",
deniedCountries: "kp",
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusForbidden)
line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
if line.RequestBytes != 0 {
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
}
if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked())
}
if calls.Load() != 0 {
t.Errorf("the app was called %d times, want none", calls.Load())
}
}
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, map[string]string{
trustedProxies: trustLocalhost,
denyNets: listedAddr,
rateLimitPerMinute: "1",
})
// listedAddr's refused requests are not counted, so the first request
// from unlistedAddr is within the limit of one a minute.
wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusTooManyRequests, requestlog.ActionRateLimited},
})
}
func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
rateLimitExemptNets: listedAddr + "," + fromKP,
deniedCountries: "kp",
rateLimitPerMinute: "1",
})
// listedAddr's requests are neither refused nor counted, so the first
// request from unlistedAddr is within the limit of one a minute. The
// country lists still refuse an exempt client.
wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusTooManyRequests, requestlog.ActionRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
})
}
// sentRequest is a GET request from client, as X-Forwarded-For names it,
// and the status and log line action it should get.
type sentRequest struct {
client string
status int
action string
}
// wantAnswers sends requests to smallwebwaf at addr one after another and
// checks each one's answer and log line. They must be the first requests
// smallwebwaf is sent, since the log lines are matched to them in order.
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
t.Helper()
for i, sent := range requests {
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, sent.client)
wantStatus(t, do(t, req), sent.status)
wantLine(t, out.requestLines(t, i+1)[i], sent.status, sent.action)
}
}
+3
View File
@@ -26,6 +26,9 @@ const (
// ActionRateLimited is a request refused because it took its client // ActionRateLimited is a request refused because it took its client
// over a rate limit, or came while the client was over one. // over a rate limit, or came while the client was over one.
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
// ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS.
ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionAdmin is a request smallwebwaf answered at one of its own // ActionAdmin is a request smallwebwaf answered at one of its own
+3
View File
@@ -186,6 +186,9 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL string) {
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m", "SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M", "SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "5G", "SWWAF_RESPONSE_MAX_BYTES": "5G",
"SWWAF_ALLOW_NETS": "",
"SWWAF_RATE_LIMIT_EXEMPT_NETS": "",
"SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000", "SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000", "SWWAF_RATE_LIMIT_PER_HOUR": "10000",
"SWWAF_RATE_LIMIT_PER_DAY": "50000", "SWWAF_RATE_LIMIT_PER_DAY": "50000",
+2 -3
View File
@@ -16,9 +16,8 @@ main() {
"$SCRIPT_DIR/check" "$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore # empty constant. The VERSION build argument takes precedence over
# excludes .git, so `git describe` in a build stage yields an empty # the version a build stage derives from the .git in the context.
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+2 -3
View File
@@ -12,9 +12,8 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore # empty constant. The VERSION build argument takes precedence over
# excludes .git, so `git describe` in a build stage yields an empty # the version a build stage derives from the .git in the context.
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \