Repository navigation
feat: endpoints with an SSRF check and a 20-endpoint limit (steps 3.2 + 3.3) - #32
Conversation
|
Read all 20 files. Approving — the SSRF work is the most thorough thing in the repo. Two findings, What convinced me. 1. DNS rebinding is the remaining gap, and I'd rather we write it down than be asked about it. Re-checking before every attempt (Phase 4) narrows this a lot, but between resolve and connect there 2. One question, not a finding:
|
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Approved. Two questions above - the DELETE route and where the DNS-pinning gap gets documented.
The base branch was changed.
… + 3.3) - POST /v1/endpoints: Zod body, SSRF check before the transaction, then lock the owner row, count live endpoints, insert; 201 with the signing secret, shown only once. - GET /v1/endpoints: live endpoints newest first, status from the newest delivery (one indexed row per endpoint), never the secret. - ssrf.ts: shape, local names, DNS with a 3 s limit, and every address must be public (IPv4 unicast and not the VPS; IPv6 also inside 2000::/3). - SSRF_ALLOW_ORIGINS: exact operator origins, empty by default. - ipaddr.js 2.5.0, pinned.
|
Rebased onto
|
a23b652 to
f8abf60
Compare
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Re-approving after the rebase: range-diff shows both commits as =, so nothing changed in the code. My three points from the earlier review are still open — the rebinding note especially, since that is the one an examiner will ask about.
What
Steps 3.2 and 3.3 of Phase 3: the SSRF check, and the first customer resource behind the auth
hook from #31,
POST /v1/endpointsandGET /v1/endpoints.Why it works this way
ssrf.tsruns four steps:shape (
https, port 443, nouser:pw, no spaces or control characters, because the URLparser would silently rewrite them), local names (
localhost, single labels,.local,.internal,.svc,.cluster.local, refused before any DNS), then every address thename resolves to (3 s limit), and every one must be public. Literal IPs skip DNS.
ipaddr.process()first, so::ffff:7f00:1is judgedas the
127.0.0.1it connects to. IPv4 must beunicastand not our VPS (81.88.23.71).IPv6 must be
unicastand inside2000::/3: ipaddr.js also calls::7f00:1and4000::1"unicast", so the label alone isn't enough. An empty list or a string that isn't anaddress is refused (fails closed, a 422 and never a 500).
private address" answer the same, so the API can't be used to map internal names.
connections; holding one, plus a lock, during a network wait would let a few slow lookups
stall the pod.
SELECT … FROM owners WHERE id = $1 FOR NO KEY UPDATEmakes two creates of the same owner take turns. The count is its own statement afterthe lock, so under READ COMMITTED it sees the other create's committed row. Without the lock
(or with the count before it) two creates at 19 both insert: the race test turns red in both
cases. The lock is per owner, so other owners never wait.
NO KEY UPDATE, notUPDATE. Every insert intoevents,endpointsordeliverieschecks its owner with a
FOR KEY SHARElock (the foreign key).FOR UPDATEblocks that, sowhile one endpoint is being created, that owner's incoming events would wait.
FOR NO KEY UPDATEstill makes endpoint creates take turns but lets events through (we never change anowner's id). A test holds the real lock and inserts an event with a 1 s lock timeout; with
FOR UPDATEit fails.whsec_+ base64 of 32 random bytes, only in the 201.GETbuilds every field by hand, so the secret can't slip into a list. Stored in plain text,unlike API keys, because the worker needs it to compute the HMAC.
failing= the newest delivery isdead, orpendingafter a failed try (attempts > 0); otherwiseactive, also with no deliveries.The newest delivery is
ORDER BY d.id DESC LIMIT 1: ids are UUIDv7, so that is newest firstand matches
deliveries_endpoint_id. With 50,000 deliveries on one endpoint, sorting bycreated_atread all 50,000 rows (6.3 ms per endpoint);id DESCreads 1 row (0.011 ms).SSRF_ALLOW_ORIGINStakes exact origins(for the Phase 4 demo receiver inside the cluster). Anything with a path, query, fragment,
credentials or another scheme stops startup. Customers can't set it.
Changes
ssrf.ts,schemas/endpoints.ts,services/endpoints.ts,repositories/endpoints.ts,routes/endpoints.ts;routes/v1.tsregisters them;app.tstakes an optional
lookup(tests use fake DNS);config.ts(SSRF_ALLOW_ORIGINS);errors.ts(invalid_endpoint_url422,endpoint_limit_reached409).ipaddr.jspinned to exactly2.5.0.[::ffff:127.0.0.1],[::127.0.0.1],[4000::1], Teredo, 6to4, NAT64, our VPS in bothforms), both ends of every reserved IPv4 block with the public neighbours just outside,
DNS errors, empty answers and timeouts, the allow-list, and
allAddressesPublicon its own.Schema tests check which field failed. Integration tests against a real Postgres: create and
list (no secret in the list), owner separation, 401, the limit with a soft-deleted endpoint
freeing a slot, two simultaneous creates at 19 (exactly one 201 and one 409, held at the lock
by
LOCK TABLE … IN SHARE MODEandpg_stat_activity, no timing guesses), another ownernot waiting, status from the newest delivery, and a failure after DNS that saves nothing and
logs no secret.
docs/api.md(no control characters inname, the URL rules, unknown fields →422, the one refusal message, the allow-list).
No migration: the tables and indexes are from migration 001.
Tested
bun run typecheck,bun run test:db: 239 pass, 0 fail.2000::/3rule, the VPS rule, the empty-list check,the
catch, orprocess()each turns at least one test red; so does removing the ownerlock or counting before it.
bun audit: no vulnerabilities (62 packages).After merge, checked from outside
https://[::ffff:127.0.0.1]/,https://[::127.0.0.1]/,https://postgres.relay.svc.cluster.local/,https://81.88.23.71/,https://api.myrelay.tech/→ all 422invalid_endpoint_url, the same message.httpsURL we control with"event_types": ["live.check"]→ 201 (thepositive control).
Still open, on purpose (Phase 4)
The worker checks the URL again before every attempt, connects only to the checked address,
gets a NetworkPolicy, and never follows redirects. A second check alone only narrows DNS
rebinding; those together close it.