Our error reporter accepted anything and forwarded it to a person
A browser-side error reporter with no origin check is not log noise — it is a trusted channel handed to strangers. What our endpoint accepted, what stood between a stranger and a message in the owner's messenger, and why tokens, per-IP limits and deduplication do not fix it.
A browser-side error reporter is three lines of JavaScript and a small endpoint, and it is the most useful thing you can add to a site: without it, front-end failures are invisible — they are not in your server logs, and the person just leaves. Ours had one property we had not thought about: it accepted text from anyone, and forwarded it to a person.
The exchange
On 10 August 2026 we sent our own endpoint a request from an address that was not ours:
POST /api/log_error.php
Origin: https://example.com
Content-Type: application/json
{"message": "<arbitrary text>", "url": "https://example.com/evil"}
It answered {"ok":true}. The text was written to the site's log and queued as an
alert with status: active.
Why nothing was delivered, and why that is not comfort
No message reached the owner's messenger — but not because of any defence. Our notifier deduplicates and waits for a REPEAT of the same alert key before sending. So what stood between a stranger's text and a personal channel was one more identical request.
That distinction matters more than it looks. «Nothing was delivered» invites you to close the ticket. «Delivery required a second request» tells you the endpoint was open and the outcome was luck.
Three defences that sound right and are not
- A token or signature. It disables the endpoint exactly when you need it most. An error reporter must work before any session exists and without a token — otherwise it cannot report the failure that broke the page in the first place, which is the only failure you truly cannot see any other way.
- A per-address limit. It caps volume, not content. Twenty different texts from one address is under any sane limit, and one crafted text is all that is needed when the destination is a human being reading a messenger.
- Deduplication. It is a noise silencer, not a boundary — and ours was the only reason the exchange above stayed quiet. Treating it as protection means treating an accident as a design.
What we did instead, and what it honestly buys
The endpoint now accepts a request only when its Origin or Referer
names one of our own hosts, and refuses when neither header is present at all. «No headers,
therefore ours» was precisely the leniency that left it open.
An origin check is not cryptography and anything that is not a browser will forge it. It is also not trying to be: it removes trivial abuse — a foreign page, a script, a crawler — and gives the endpoint its purpose back, which is receiving errors from OUR front end. A real boundary is impossible here, for the reason in the first bullet above.
And one thing that is easy to skip: after tightening it, we checked in a live browser that OUR
OWN errors still arrive. A single-domain fetch from page code sends no
Origin header at all — measured, not assumed — so an implementation that required
only Origin would have silenced our own reporting completely. Both outcomes look
equally calm in a test run and equally calm in an alert channel: no messages.
The self-check below is the list we ran against ourselves. It grades consequences, not points.
Check your endpoint against eight points
Tick what you have closed. Your browser does the counting, nothing ticked is sent anywhere, and there is no send code on this page.
Read next
-
298 checks passed. The defect shipped anyway.
Our suite was green at 298 checks and covered only input refusals. The useful unit is not tests — it is classes of path: refused at the door, refused from outside, success, repeat.
-
The response said 200. The body said no.
Checking status === 200 lets refusals through. Ours broke retries on two sites and one widget, and put a fabricated cause in the log.
The general shape, which is the part worth keeping: work out where accepted input ENDS UP before you decide how urgent an open endpoint is. The same missing check is log noise when the destination is a file, and a channel handed to strangers when the destination is a person.
The second half is the one that stays broken quietly: after tightening any input, measure that your own traffic still gets through. Refusing strangers and refusing everyone look identical from the outside, and both produce a calm test run.
How our gateway handles input and refusals · Tell us if you found a hole in ours