What this service protects, what it deliberately does not, and what I would change before it took production traffic.
The endpoint is unauthenticated. The contract declares security: [], and
this implementation follows it rather than inventing a scheme —
ADR-0011.
It is not intended to face the internet without a gateway in front of it. This is stated here rather than left to be discovered.
| Data | Sensitivity | Handling |
|---|---|---|
| License plate number | PII — identifies a person and their movements | Stored raw (evidence) and canonical (matching). Never logged. |
| Plate jurisdiction | Low on its own | Stored as read; not validated against any list |
| Transponder id | PII — tied to a customer account | Stored raw and canonical. Never logged. |
location |
Low; combined with a plate it is a movement record | Stored verbatim as JSONB |
metadata |
Unknown — producer passthrough | Stored verbatim, never interpreted or logged |
base_amount |
Commercial | Stored exactly, plus the producer's original text |
metadata deserves a note. It is free-form and "not interpreted", which means
a producer can put anything in it, including data nobody agreed to collect. It
is stored and never logged, but it is worth an explicit conversation with each
producer about what they actually send.
Plate and transponder values must never reach log aggregation — typically the least access-controlled and longest-retained place data goes.
Two layers enforce it:
- No code path logs them. Rejections log field names and reasons, never values. Tests assert this for both accepted and rejected requests.
- A redacting
sloghandler replaces sensitive attribute values at every nesting depth, including inside groups.
The second exists because the first depends on everyone remembering. If someone
adds slog.String("plate_number", …) in a hurry, the output is [REDACTED]
rather than a disclosure.
The known limitation: a value interpolated into a message string is not caught. There is a test documenting exactly that, so it is a known boundary rather than a false sense of safety.
The event published to the resolution pipeline does carry identifiers. That pipeline exists to attribute vehicles and is inside the trust boundary. The rule is about logs, not about the domain.
The service accepts data from systems it does not control.
| Concern | Mitigation |
|---|---|
| Malformed or hostile JSON | Validated against the OpenAPI contract before any handler runs |
| Oversized fields | maxLength from the contract, enforced by the validator |
| Oversized request body | Capped at 1 MiB by http.MaxBytesReader, before anything reads it. Over the limit is a 413 naming the limit. |
| Unbounded reflection | Producer values repeated in an error are truncated. The contract puts no maxLength on transaction_type. |
Metrics cardinality via source |
Producer-supplied label values are capped at 200 distinct; the rest collapse into other. |
| Readiness disclosure | /readyz returns unavailable; the driver error naming host, port, and user goes to the logs. |
| Deeply nested JSON | Rejected with a 400; the process survives. Verified against 50k-deep nesting. |
| SQL injection | Parameterised queries throughout; no string-built SQL |
| Slowloris | ReadHeaderTimeout set explicitly, independent of ReadTimeout |
| Panic as denial of service | Recovery middleware; one request cannot take down a process serving others |
| Attacker-controlled log content | Inbound X-Request-Id is length-capped before it reaches a log record |
| Metrics cardinality attack | Route labels use the registered mux pattern; unmatched paths collapse to unmatched, so random URLs cannot create unbounded series |
| Error responses leaking internals | Storage and panic errors return a status and nothing else; detail goes to logs |
source is self-declared. It is also half the idempotency key.
A misconfigured — or malicious — producer claiming another's source can
collide with that producer's namespace: their transaction would be reported as a
duplicate of ours, or ours would suppress theirs. With no authentication there
is nothing preventing this.
Whatever authentication is added, it should bind the credential to an allowed
source set and reject mismatches. That closes the namespace problem, not
just the access one.
govulncheckruns in CI and fails the build on a known vulnerability in anything reachable from this code. It has already caught an out-of-date Go toolchain.- The Go toolchain version is pinned in
go.mod, so a security patch is a one-line bump that CI verifies. gitleaksscans history for committed secrets on every run.- Dependabot proposes updates weekly, targeting
develop. - Runtime images are distroless static, non-root: no shell, no package manager, no libc. Nothing to exploit interactively and very little to patch.
- Dependencies are few and deliberate — see ADR-0001.
The service was probed while running, not just read. Four issues were found and fixed; each has a regression test that fails without its fix.
| # | Issue | Measured before the fix | After |
|---|---|---|---|
| 1 | No request body limit | A 61 MB body was accepted with 201, cost ~128 MB of heap, and was stored permanently in metadata |
413, heap +5 MB |
| 2 | Unbounded metric cardinality | 300 distinct source values produced 302 Prometheus series, on an unauthenticated endpoint |
400 producers → 201 series, capped |
| 3 | Unbounded reflection | A 4 MB transaction_type was echoed back verbatim — a 4 MB error response |
488 KB in → 175 bytes out |
| 4 | Readiness disclosure | /readyz published the database host, port, user, and database name to anyone who could reach it |
unavailable; detail to the logs |
Issues 1 and 2 are the ones that mattered. Both are trivially reachable on an endpoint the contract declares unauthenticated, and both exhaust memory — issue 2 takes the monitoring backend with it, which is what you need most while under attack.
Issue 2 is worth dwelling on as a mistake: the cardinality risk on the route
label had already been reasoned about and defended, while source — the label
an attacker actually controls — was left unbounded a few lines away.
Checked and found sound: SQL is parameterised throughout with no string-built
queries; deeply nested JSON is rejected without exhausting the stack; reflected
input is JSON-escaped and correctly typed; error responses are smaller than
their requests; the database password never appears in logs or in /readyz;
govulncheck is clean and gitleaks finds nothing across all 32 commits.
In priority order.
- Authenticate producers, with mTLS. They are known, long-lived systems
rather than self-service signups. Client certificates give per-producer
identity with rotation and revocation that shared secrets do not, and
terminate cleanly at an ALB or service mesh. Bind each certificate to an
allowed
sourceset. - Rate-limit per producer. A looping lane controller should degrade its own throughput, not everyone's. The body limit bounds a single request; nothing currently bounds the rate, which is the remaining denial-of-service gap and the first thing I would add after authentication.
- Encrypt at rest and restrict access. RDS encryption is in the Terraform;
what is missing is a policy on who can read the
transactionstable, because that table is a movement database. - Define retention. Plate reads should not be held forever. Tolling has statutory retention periods and they vary by jurisdiction — this needs a legal answer, not an engineering one.
- Audit reads, not just writes. The write path is fully auditable today. Who queried a plate is not recorded, and for movement data that is usually the question an investigation actually asks.
This is an interview exercise, not a deployed system. If you find something in it, please mention it directly — I would rather hear about it.