example.lrs.amtechhq.com / lead-routing-system

fraud — the form below is real, and so is everything it reports

Fraud watches a form being filled in and tells an operator which submissions deserve a human look. It advises; it never blocks a sale and it never auto-rejects. This page is a seller's page: it loads the collector from the deployed service, that collector mints a session, reports what it observes, and posts a snapshot of this page when you submit — and the panels beside the form show you exactly what went over the wire.

Get a quote

An ordinary seller's lead form. It submits nowhere. There is no lead here and no buyer to sell one to, so this page stops the submission after the collector has done its work. Type into it, paste into it, tab around it — the collector is watching all of that and the panel beside this one shows what it made of it.

Two fields on this form are here to prove something

The password field is captured by nothing. In the interaction stream its value_length is reported as 0, so not even the length of a password leaves this page; in the DOM snapshot it is marked data-lrs-redacted="password" rather than left looking empty, so a reviewer can tell we did not take this from they typed nothing.

The honeypot is not in the markup above. The collector appends a hidden input[name=lrs_hp] to every <form> on the page when it starts. A form-filling bot fills every input it finds, including that one. It is reported as filled or not and never as what was typed into it — whatever a bot types there is still somebody's data.

What the collector did

inspector
  • Session keynone — the collector needs JavaScript to run at all
  • Statusnot run
  • Seller campaign keyexample-site
  • Endpointhttps://fraud.lrs.amtechhq.com

The collector mints a session on load. If this row still says not run, JavaScript is off — and that is precisely the state the service records as unscored, never as clear.

On the wire

nothing yet
  • Nothing yet.

Live. New requests append here and the pane follows the newest one. Pause holds the view still so you can read a body without it moving; nothing is dropped while paused, and the button says how many are waiting.

The page’s own health check, and what it is honestly able to say

Health

not asked

This check runs in your browser and has not run yet. With JavaScript off it never runs, and this line stays as it is — which is the honest answer: nothing was asked, so nothing is known.

This route answers for the storage as well as the process. A 503 here means the configured media root is unusable — a silently unmounted NAS leaves a writable empty directory behind, so the check looks for a marker file rather than trusting that a write succeeds. It is a status code and not a warning field because the deploy's auto-revert reads status codes.

The one thing this page cannot show you, and will not pretend to

There is no score on this page. The collector reports observations; the server decides. Reading back what it decided — the verdict, the total, the threshold in force, which rules fired — is GET /api/v1/sessions/{key}/score, which requires the service credential, and a public page must not carry one.

That is not an oversight in the demonstration, it is the second of the four properties that make an anonymous write boundary safe: it reads nothing back. A caller who could ask whether their own traffic had been flagged would have a tuning loop for evading the rules. Press the read the score button below and watch the real service refuse it.

Seeing a score needs an operator login to the console at admin.lrs.amtechhq.com/fraud, which will redirect you to a login form this site has no account for.

Try to break it — live, against the real deployment

Each button sends a request the service is designed to refuse and shows you the refusal it actually returned. Nothing is simulated. Nothing reaches a table.

1. Assert that you are not fraudulent

The legacy system this service replaces took IS_FRAUD as an ordinary form field, and that field decided distribution — so any submitter could set it to 0. This posts {"name":"is_fraud","value":"0"} as a signal on your live session.

The refusal list is belt and braces, and the service's own source says so. The property that actually holds is structural: every rule weight is CHECKed positive and the scorer only ever adds, so an observation a caller invents can raise a total and can never lower one. There is no observation that clears anything.

2. Ask the server to fetch a URL for you

Legacy's page capture sent the list of URLs the page referenced and the server retrieved every one of them, following redirects, with no scheme allowlist and no private-address rejection, from an unauthenticated endpoint. This posts a snapshot with an extra document_url field pointing at the cloud metadata service.

The snapshot route takes one string of markup. There is no field for a URL, a host, a path or a filename, and unknown fields are refused — so the field cannot be added without somebody also deciding to honour it. A test in the repository drives twelve spellings of a URL list against this route, and a second reads the package's whole source and fails the build on an outbound HTTP client, an exec.Command or a net.Dial.

3. Read your own score

The tuning loop. If this worked, a caller could vary their behaviour until the service stopped flagging them.

$ curl -s -w '\n# HTTP %{http_code}\n' \
    https://fraud.lrs.amtechhq.com/collect/9ef8f80a29b60bfed257d0cfd323fc9c/score
404 page not found
# HTTP 404

Recorded 2026-09-06. There is no such route, which is a stronger answer than a refusal: GET /api/v1/sessions/{key}/score exists and is behind the credential, and nothing on the public boundary reads it back under any name.

4. Read the console

Session counts, rules, verdicts, the threshold. Everything an operator sees.

This one is refused twice over and your browser will probably only show you the second refusal: no CORS header on /api/v1, so the browser will not let this page read the response at all. Here is what it actually says, recorded 2026-09-06:

$ curl -s https://fraud.lrs.amtechhq.com/api/v1/console/overview
{"success":false,"errors":["invalid service credential"],"meta":{"timestamp":"2026-09-06T17:54:42Z"}}
$ # HTTP 401

The recording policy for this campaign — live

GET /collect/example-site/recording-policy

Not read. This panel makes the request from your browser; with JavaScript off it is not made, and nothing here is filled in from anywhere else.

This is the only route on the public boundary that reads anything back, and the service's source names it rather than leaving it to be discovered. It is not a hole in the property above: the prompts are operator-authored text whose entire purpose is to be shown to a member of the public on that campaign's own page. They carry no observation, no score and no session, and a collector that could not read them could not show anybody the words they were agreeing to.

If a modality were enabled, the collector would show that campaign's exact words in a shadow root, post the person's answer before touching getUserMedia, and post the browser's answer separately afterwards — because a browser can grant a permission from a decision somebody made on an earlier visit without seeing anything today, and a browser permission is not consent. Audio is never requested on either modality.

The collector itself

Are the bytes served today the bytes this page was built against?

Not checked. The comparison is computed in your browser from the bytes the service serves right now.

The script is fixed, reviewed source, embedded in the service binary and served at a stable path. It is never generated, never templated and never concatenated with rule data. Everything per-campaign arrives as JSON in a block the script parses:

<script type="application/json" id="lrs-fraud-manifest">
{"endpoint":"https://fraud.lrs.amtechhq.com","campaign_key":"example-site","signals":[…]}
</script>
<script src="https://fraud.lrs.amtechhq.com/collect/collector.js" defer></script>

Rule names, signal names and condition values are text an operator typed into a console. Splicing them into JavaScript would make the rules table a code-execution surface on every seller's page, so they never enter the script — they arrive as data, and Go's JSON encoder turns < and > into escapes, so a campaign key containing </script> cannot close the block.

Two honest differences between the snippet above and the production one.

First, the production snippet carries integrity="sha384-…" and crossorigin="anonymous", computed from the bytes actually served. This page reports the comparison instead of enforcing it: an integrity attribute that stopped matching would stop the collector loading, and a demonstration page that demonstrates nothing is a worse failure here than a redeploy going unnoticed. The panel above is that check, done in the page.

Second, in production the signals list is not written by hand. It comes from GET /api/v1/console/integration/{seller_campaign_key}, which composes the whole snippet from the campaign's active rules — and, in the same response, names every active rule whose signal the collector cannot produce, with a sentence written to be shown to an operator verbatim. That read needs the service credential. So the manifest on this page lists all fifteen signals the collector is capable of producing, which is a superset of any real campaign's list and is a fact about the script rather than a claim about this campaign's rules.

The fifteen signals — from source: cmd/fraud/assets/collector.js

SignalWhenWhat it observes
js_enabledloadConstant true. Its absence is the signal: a session that reported nothing is unscored.
navigator_webdriverloadThe browser's own automation flag.
iframe_webdriverloadThe same flag read from a fresh same-origin iframe. A page-level override does not survive into one, so a disagreement between the two is itself evidence.
known_automation_globalsloadEleven named globals, plus any cdc_-prefixed property on document.
ua_has_headless_keywordsloadHeadless, phantom, puppeteer, playwright and friends in the user agent.
fn_tostring_tamperedloadWhether Function.prototype.toString has itself been rewritten — which is how a patched navigator stays invisible to the probes above.
fetch_tamperedloadWhether window.fetch is still native. This page's inspector trips it.
plugins_emptyloadWhether navigator.plugins is empty.
canvas_hashloadAn 8-hex FNV-1a of a fixed canvas rendering. A fingerprint, not a value anybody typed.
webgl_vendorloadThe unmasked WebGL vendor string, or nothing if the extension is absent.
webgl_rendererloadThe unmasked WebGL renderer string, likewise.
honeypot_filledsubmitFilled or not. Never what was typed.
paste_countsubmitHow many paste events happened.
rapid_input_countsubmitInput events less than 30 ms apart.
interaction_countsubmitPointer-downs and key-downs.

The four at the bottom are sent on submit and on pagehide, never at load. Legacy computed its behavioural checks during page load, before any interaction had happened, so every one of them was structurally dead and its mouse check flagged all desktop traffic. A counter read before the visitor has done anything is not a measurement.

A probe that throws contributes nothing rather than a false. Could not look and looked and found nothing are different facts, and the service records them differently.

About the rule weights quoted on this page. They come from fraud/migrations/0002, which seeds ten rules — nine active and one (JavaScript Disabled) seeded already archived — and a threshold of 100. That is a fact about the migration, not a claim about what the live rules table holds today: an operator can add, edit, archive and restore rules, and reading which ones are in force needs the service credential. Rules are never DELETEd — archive and restore, so the receipt on an old score still names a rule somebody can look up. This page quotes the seeded weights and does not compute a total from them.

What else went over the wire, and what deliberately did not

The interaction stream

Typed events with millisecond offsets from one origin, the field they were on, and how many characters that field held: pointerdown, keydown, input, change, focus, blur, paste, copy, scroll, submit. Batched, and idempotent per (session, sequence), so a flush that may or may not have arrived can simply be sent again.

It does not capture keystrokes. The keydown handler has e.key in its hand and does not read it; there is no field on the wire that could carry it and no column behind it. Legacy recorded the actual character for every single-character key and kept it forever, so anything typed into a captured field was reconstructible character by character out of the database. The behavioural value is entirely in the type, the timing and the length — those travel, the content does not, and the column that would hold it does not exist, which is a stronger statement than a policy saying not to write to it.

Legacy's mousemove is deliberately absent. It was that system's highest-volume event and the cheapest of all of them to synthesise.

The DOM snapshot

Taken at submit, and the sensitive one, because it carries the values somebody typed. Built entirely in this browser: scripts removed, every URL-bearing attribute moved to an inert data-lrs-* attribute, every CSS url() defused, and every readable stylesheet inlined. A cross-origin stylesheet throws on .cssRules and is skipped — which is precisely where a replay renders degraded, and that degradation is the feature, because the alternative is the server retrieving a URL on a stranger's say-so.

Over the size ceiling it is not sent at all. Truncated markup is a lie about what the page looked like, not a smaller snapshot.

Retention, which is the reason any of this is storable

Three windows, and they are deliberately not one number: recordings and page snapshots at 7 days, signals and the interaction stream at 30, the score receipt at 365. Expiry is a sweep that DELETEs on a timer, never a read-time filter — a filter leaves the rows on disk and calls them deleted. The system this replaces held 670,389 rows of captured page content and 93,799 form sessions going back 290 days, with no retention limit, because nothing was ever configured to delete it and no row existed to say what the answer should have been.

Verify any of it yourself — recorded 2026-09-06

$ curl -s -X POST -H 'Content-Type: application/json' -d '{}' \
    https://fraud.lrs.amtechhq.com/collect/example-site/session
{"session_key":"9ef8f80a29b60bfed257d0cfd323fc9c"}   # HTTP 201

$ curl -s https://fraud.lrs.amtechhq.com/collect/example-site/recording-policy
{"screen":{"mode":"off"},"webcam":{"mode":"off"}}

$ K=9ef8f80a29b60bfed257d0cfd323fc9c
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
    -d '{"signals":[{"name":"js_enabled","value":"true","observed_at":"2026-09-06T17:55:00Z"}]}' \
    https://fraud.lrs.amtechhq.com/collect/$K/signals
204

$ curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"signals":[{"name":"is_fraud","value":"0","observed_at":"2026-09-06T17:55:00Z"}]}' \
    https://fraud.lrs.amtechhq.com/collect/$K/signals
{"error":"this boundary records observations only: is_fraud is not something a client may report"}
# HTTP 422

$ curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"document_html":"<html></html>","document_url":"http://169.254.169.254/",
         "viewport_width":1,"viewport_height":1,"captured_at":"2026-09-06T17:55:00Z"}' \
    https://fraud.lrs.amtechhq.com/collect/$K/snapshot
{"error":"a JSON body with document_html, viewport_width, viewport_height and captured_at is
  required, and it may carry nothing else"}
# HTTP 400

What is behind the credential, in full

Everything below needs Authorization: Bearer <service key>, which this page does not have and must never have. Listed so you can see the size of what is not on this page: