Milestone 1: pass-through proxy with read and write timeouts, size limits and a request log #13

Open
opened 2026-09-25 10:34:34 +02:00 by clawbot · 1 comment
Collaborator

Directive (sneak, in chat, 25 September 2026), verbatim:

write a first milestone for smallwebwaf that implements the basic proxy functionality, allows an http request to pass through, and implements timeouts for read and write from downstream client and upstream server and enforces maximum request/response sizes, and logging. design it with an eye to have the second milestone being simple per-ip rate limits and country white/blacklisting. we're going to get it into prod by the second milestone, so the most basic mvp is going to be fit for purpose for rate limiting and country filtering.

and minutes later:

write the second milestone too. each one should be one issue.

Milestone 2 is #14. This milestone is the repo's first code and lands as one PR to next.

What it does

  • Passes each HTTP request to the app, and the app's answer back, unchanged: method, path, query, headers, body and status. Bodies stream in both directions and are never held whole in memory. WebSocket upgrades pass through, and an upgraded connection is not cut by the timeouts.
  • Enforces the four timeouts and two size limits below. A limit passed before the response has started gets smallwebwaf's own answer: 408 for a client too slow to send its request, 413 for a request body that is too large, 504 for an app too slow to answer, 502 for a response that is too large or an app that cannot be reached. A request that announces a body over the limit is refused before anything reaches the app. After the response has started, a limit can only cut the connection.
  • Writes one JSON line per request to stdout (see "Request log").

Settings

Each is an environment variable with the SWWAF_ prefix (#12 (comment)) and has a default, so none must be set. A value that is set but invalid stops the start with a message naming the variable. The effective settings are logged at start.

  • SWWAF_LISTEN_ADDR, default :8080.
  • SWWAF_UPSTREAM_URL, default http://127.0.0.1:8081: the app, as in the deploy model of issue 12.
  • SWWAF_TRUSTED_PROXIES, default 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16. A list given replaces the default; set but empty, it trusts nothing (the standing ruling for all apps).
  • Timeouts, with the defaults of #5:
    • SWWAF_CLIENT_REQUEST_TIMEOUT, 60s: reading the whole request from the client.
    • SWWAF_CLIENT_RESPONSE_TIMEOUT, 30m: writing the whole response to the client.
    • SWWAF_UPSTREAM_REQUEST_TIMEOUT, 60s: connecting to the app and writing the whole request to it.
    • SWWAF_UPSTREAM_RESPONSE_TIMEOUT, 30m: reading the whole response from the app.
  • Size limits, with the defaults of issue 5:
    • SWWAF_REQUEST_MAX_BYTES, 100M: the largest request body.
    • SWWAF_RESPONSE_MAX_BYTES, 5G: the largest response body.

Fixed, not settings: the request line and headers may take up to 32 KiB, above which the answer is 431 and nothing reaches the app. A kept-open client connection idle for 120 seconds is closed. That is longer than traefik's 90 seconds, so traefik never sends a request on a connection smallwebwaf is closing.

Client address

Worked out for every request, for the log now and for milestone 2's limits and country lists:

  • A TCP peer outside SWWAF_TRUSTED_PROXIES is the client, and its forwarded headers are ignored.
  • For a peer inside it, X-Forwarded-For is read from the right, and the first address outside SWWAF_TRUSTED_PROXIES is the client. If every address in it is inside, the leftmost is the client; with no header, the peer.

The app sees what it would see from traefik directly: the same Host and X-Forwarded-Proto, and X-Forwarded-For with smallwebwaf's peer added at the end. Forwarded headers from a peer outside SWWAF_TRUSTED_PROXIES are replaced, not passed on.

Request log

One JSON object per request on stdout, refused ones included, using the field names of the "Request log" section of SPEC.md in #9:

  • time, client_ip, peer_ip, method, host, path, query, protocol, status, upstream_status, request_bytes, response_bytes, referer, user_agent;
  • action: forward, too_large, timed_out or upstream_error;
  • aborted when the client went away early;
  • duration_total and duration_upstream_total, in milliseconds.

No body and no other header is logged. The process's own messages (start, settings, errors) are JSON lines on the same stream, told apart by "type":"process" and "type":"request".

Built for milestone 2

  • One place in the request path, after the client address is known and before the body is read or anything reaches the app, where a check can refuse a request with a status and an action for the log. Milestone 2's country and rate checks go there.
  • The log line takes milestone 2's added action values and fields without other changes.
  • Settings are read in one place, with the parsing of netblocks, durations, sizes and lists that milestone 2 reuses.

Definition of done

  • Tests against real local HTTP servers show:
    • a request and its answer passing through unchanged, bodies included;
    • each timeout and each size limit tripping at its setting, with the answer and action above, and the log line saying which;
    • the client address rules, including a forged X-Forwarded-For from a peer outside SWWAF_TRUSTED_PROXIES, and the headers the app receives;
    • every setting's default, and the start refused for each kind of invalid value.
  • README.md says what is built, how to run it, and every setting with its default; it no longer says that no code exists.
  • The repo follows the Go repo policies of the prompts repo (layout, make targets), and make check is green.
  • One PR to next, passed by a reviewer who did not write it.

Not in this milestone

Rate limits, country lists and the container image of issue 12 are milestone 2. Bans, rule files, the Core Rule Set, AS number lookups, reputation sources, alerts, metrics, the admin listener and remote log sending stay in SPEC.md for later milestones; each is an issue of its own, 17 to 29 on this repo. The MIT LICENSE, sneak's pick, is its own issue: #15.

Choices made

Say on this issue to change any.

  • One size limit per direction, not a client-side and an app-side one each as issue 5 asked: smallwebwaf passes bodies through unchanged, so the two would bound the same bytes and the lower would always act. The timeouts keep both sides, since there the sides differ.
  • The header size and the idle time are fixed values, not settings.

Order

Starts when a worker account resets, 27 September at 23:00 UTC at the earliest, after webhooker and pixa (sneak's ruling of 24 September). The spec update (#9) and the deploy-model spec (issue 12) continue beside it. Where they differ from this issue on what is built first, this issue holds, and PR 9's rework brings the spec's build order in line.

Model: opus-5-5

Directive (sneak, in chat, 25 September 2026), verbatim: > write a first milestone for smallwebwaf that implements the basic proxy functionality, allows an http request to pass through, and implements timeouts for read and write from downstream client and upstream server and enforces maximum request/response sizes, and logging. design it with an eye to have the second milestone being simple per-ip rate limits and country white/blacklisting. we're going to get it into prod by the second milestone, so the most basic mvp is going to be fit for purpose for rate limiting and country filtering. and minutes later: > write the second milestone too. each one should be one issue. Milestone 2 is https://git.eeqj.de/sneak/smallwebwaf/issues/14. This milestone is the repo's first code and lands as one PR to `next`. ## What it does - Passes each HTTP request to the app, and the app's answer back, unchanged: method, path, query, headers, body and status. Bodies stream in both directions and are never held whole in memory. WebSocket upgrades pass through, and an upgraded connection is not cut by the timeouts. - Enforces the four timeouts and two size limits below. A limit passed before the response has started gets smallwebwaf's own answer: `408` for a client too slow to send its request, `413` for a request body that is too large, `504` for an app too slow to answer, `502` for a response that is too large or an app that cannot be reached. A request that announces a body over the limit is refused before anything reaches the app. After the response has started, a limit can only cut the connection. - Writes one JSON line per request to stdout (see "Request log"). ## Settings Each is an environment variable with the `SWWAF_` prefix (https://git.eeqj.de/sneak/smallwebwaf/issues/12#issuecomment-102934) and has a default, so none must be set. A value that is set but invalid stops the start with a message naming the variable. The effective settings are logged at start. - `SWWAF_LISTEN_ADDR`, default `:8080`. - `SWWAF_UPSTREAM_URL`, default `http://127.0.0.1:8081`: the app, as in the deploy model of issue 12. - `SWWAF_TRUSTED_PROXIES`, default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. A list given replaces the default; set but empty, it trusts nothing (the standing ruling for all apps). - Timeouts, with the defaults of https://git.eeqj.de/sneak/smallwebwaf/issues/5: - `SWWAF_CLIENT_REQUEST_TIMEOUT`, `60s`: reading the whole request from the client. - `SWWAF_CLIENT_RESPONSE_TIMEOUT`, `30m`: writing the whole response to the client. - `SWWAF_UPSTREAM_REQUEST_TIMEOUT`, `60s`: connecting to the app and writing the whole request to it. - `SWWAF_UPSTREAM_RESPONSE_TIMEOUT`, `30m`: reading the whole response from the app. - Size limits, with the defaults of issue 5: - `SWWAF_REQUEST_MAX_BYTES`, `100M`: the largest request body. - `SWWAF_RESPONSE_MAX_BYTES`, `5G`: the largest response body. Fixed, not settings: the request line and headers may take up to 32 KiB, above which the answer is `431` and nothing reaches the app. A kept-open client connection idle for 120 seconds is closed. That is longer than traefik's 90 seconds, so traefik never sends a request on a connection smallwebwaf is closing. ## Client address Worked out for every request, for the log now and for milestone 2's limits and country lists: - A TCP peer outside `SWWAF_TRUSTED_PROXIES` is the client, and its forwarded headers are ignored. - For a peer inside it, `X-Forwarded-For` is read from the right, and the first address outside `SWWAF_TRUSTED_PROXIES` is the client. If every address in it is inside, the leftmost is the client; with no header, the peer. The app sees what it would see from traefik directly: the same `Host` and `X-Forwarded-Proto`, and `X-Forwarded-For` with smallwebwaf's peer added at the end. Forwarded headers from a peer outside `SWWAF_TRUSTED_PROXIES` are replaced, not passed on. ## Request log One JSON object per request on stdout, refused ones included, using the field names of the "Request log" section of `SPEC.md` in https://git.eeqj.de/sneak/smallwebwaf/pulls/9: - `time`, `client_ip`, `peer_ip`, `method`, `host`, `path`, `query`, `protocol`, `status`, `upstream_status`, `request_bytes`, `response_bytes`, `referer`, `user_agent`; - `action`: `forward`, `too_large`, `timed_out` or `upstream_error`; - `aborted` when the client went away early; - `duration_total` and `duration_upstream_total`, in milliseconds. No body and no other header is logged. The process's own messages (start, settings, errors) are JSON lines on the same stream, told apart by `"type":"process"` and `"type":"request"`. ## Built for milestone 2 - One place in the request path, after the client address is known and before the body is read or anything reaches the app, where a check can refuse a request with a status and an `action` for the log. Milestone 2's country and rate checks go there. - The log line takes milestone 2's added `action` values and fields without other changes. - Settings are read in one place, with the parsing of netblocks, durations, sizes and lists that milestone 2 reuses. ## Definition of done - Tests against real local HTTP servers show: - a request and its answer passing through unchanged, bodies included; - each timeout and each size limit tripping at its setting, with the answer and `action` above, and the log line saying which; - the client address rules, including a forged `X-Forwarded-For` from a peer outside `SWWAF_TRUSTED_PROXIES`, and the headers the app receives; - every setting's default, and the start refused for each kind of invalid value. - `README.md` says what is built, how to run it, and every setting with its default; it no longer says that no code exists. - The repo follows the Go repo policies of the `prompts` repo (layout, `make` targets), and `make check` is green. - One PR to `next`, passed by a reviewer who did not write it. ## Not in this milestone Rate limits, country lists and the container image of issue 12 are milestone 2. Bans, rule files, the Core Rule Set, AS number lookups, reputation sources, alerts, metrics, the admin listener and remote log sending stay in `SPEC.md` for later milestones; each is an issue of its own, 17 to 29 on this repo. The MIT `LICENSE`, sneak's pick, is its own issue: https://git.eeqj.de/sneak/smallwebwaf/issues/15. ## Choices made Say on this issue to change any. - One size limit per direction, not a client-side and an app-side one each as issue 5 asked: smallwebwaf passes bodies through unchanged, so the two would bound the same bytes and the lower would always act. The timeouts keep both sides, since there the sides differ. - The header size and the idle time are fixed values, not settings. ## Order Starts when a worker account resets, 27 September at 23:00 UTC at the earliest, after webhooker and pixa (sneak's ruling of 24 September). The spec update (https://git.eeqj.de/sneak/smallwebwaf/pulls/9) and the deploy-model spec (issue 12) continue beside it. Where they differ from this issue on what is built first, this issue holds, and PR 9's rework brings the spec's build order in line. Model: opus-5-5
clawbot self-assigned this 2026-09-25 10:34:34 +02:00
Author
Collaborator

Edited: the definition of done no longer adds an MIT LICENSE. sneak picks the licence of an unlicensed repo himself (sneak/keyfunc#14 (comment)); smallwebwaf's is #15.

Model: opus-5-5

Edited: the definition of done no longer adds an MIT `LICENSE`. sneak picks the licence of an unlicensed repo himself (https://git.eeqj.de/sneak/keyfunc/issues/14#issuecomment-102984); smallwebwaf's is https://git.eeqj.de/sneak/smallwebwaf/issues/15. Model: opus-5-5
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/smallwebwaf#13