Skip to content
NOSTREL
Security

We went looking for
ways to steal from ourselves.

Six passes, each one written as an attacker rather than as a reviewer, and this page is what they found. Including the pass that discovered our own ledger could be credited by anyone willing to send one unauthenticated POST.

A security page that lists only successes is a marketing page. The useful version names the failures, because the thing you actually want to know about a payments company is not whether it has ever been wrong. It is what happens when it is.

6

adversarial passes

Each with a written brief and a record.

3

critical findings

All closed, all described below.

115

handlers traced

Every route, to the service behind it.

434

leaked credentials

In one local session, before the fix. Now zero.

01Method

Six briefs, written as somebody trying to get in

Not a checklist walked top to bottom. Each pass had a goal, and the goal was to succeed at something we would hate.

  1. 01

    Information disclosure

    What an error page, a log line, a response header or a network tab gives away to somebody who should not have it.

  2. 02

    Authentication and session

    Tokens, cookies, rotation, revocation, and what actually happens when somebody signs out.

  3. 03

    Authorization on every route

    Every handler enumerated and traced to the service behind it, looking for one that trusts an id from the request.

  4. 04

    Money integrity and callback authenticity

    Whether a stranger with curl can make the ledger believe a payment happened.

  5. 05

    Injection, request forgery and uploads

    Raw SQL, dynamic query fragments, outbound URLs we are told to fetch, and files we are handed.

  6. 06

    Transport, headers and the edge

    What the browser is told to enforce, and what the thing in front of the application does when someone is rude to it.

02What they found

The three worst, in full

Each one closed, each one described specifically enough that you can judge whether the fix is real or a patch over a symptom.

Finding 01Criticalclosed

You could create money with curl

What was there

Safaricom does not sign its callbacks. There is no HMAC, no mutual TLS and nothing in the request that proves the sender, and the endpoint has to be on the public internet because that is where the provider calls from. Our handler matched the id in the body, saw a success code, and credited the merchant ledger. The amount comes from our own record rather than the callback, so a forger could not name the figure, but they did not need to: raise a collection to a number you control, never pay it, post the callback yourself, withdraw.

Why it was bad

The controller's own comment said production hardens this with an allowlist and an unguessable path. No code did either. It had been thought about, written down, and never built, and nothing in the repository disagreed with the comment.

What changed

A callback is now authenticated at the edge before it reaches the application, and, more importantly, a success callback no longer credits anything on its own. It parks the payment and we ask the provider directly whether that transaction exists. That second part is the real fix: it holds even if the first one is bypassed, because it does not depend on believing the message.

Finding 02Criticalclosed

Every refresh token was in the logs

What was there

The log redaction list covered the request cookie header and not the response one. So every sign-in, registration and refresh wrote a fourteen-day credential into the application log in plaintext. A single local session produced 434 of them.

Why it was bad

Logs go to stdout, which goes to the container runtime, which goes to whatever aggregator a deployment uses. The blast radius is everyone with log access, every log backup, and anyone who ever compromises the log pipeline. It is not an attack on the application, it is an attack on the thing watching it.

What changed

A denylist is the wrong shape for this problem, because it has to be complete forever, including for fields nobody has added yet. Replaced with serializers that allow a fixed set and drop everything else, so a new header cannot leak by default. Verified against a live server: 434 occurrences became zero.

Finding 03Criticalclosed

Signing out of the platform console did not sign you out

What was there

The admin console's sign-out cleared the access token held in memory and set the interface to signed-out. That was all it did. It called no endpoint, and that client had no method to call. The refresh cookie stayed in the browser and its token family stayed live on the server.

Why it was bad

The next person to open that console on the same laptop was handed a fresh platform-staff token by the normal startup refresh. On the console that suspends merchants, approves verification, changes bank destinations and can reveal payer identities.

What changed

Sign-out now calls the endpoint, which revokes the whole token family server side and clears the cookie. The server half had always been correct. The lesson was about drift: two near-identical clients, and the one that had quietly lost a method was the half with more to lose.

03Came back clean

What we looked hard at and did not find

Worth listing only because each item names what was checked, which is the part that makes it a statement rather than an assurance.

  • Authorization

    115 handlers enumerated, every one traced to the service method behind it. Each either asserts a membership permission, scopes its query by merchant, sits behind the platform guard, is user-scoped, or is deliberately public. No cross-merchant record access was reachable.

  • SQL

    Every raw query is a tagged template, so the driver parameterises it. The dynamic fragments in the transaction and order queries interpolate only literals chosen in our own code, never a user-supplied column or sort direction.

  • Refresh tokens

    256-bit random values, stored as hashes, rotated on use, with family-wide revocation when a revoked token is presented again. This part was already the design you would ask for.

  • Secrets in the repository

    The seeded credentials are documented as local-development only, and the production boot refuses to start on the development signing, encryption or callback secrets.

  • The OpenAPI document

    Served only outside production, with the risk console excluded from it, so reading our schema does not teach a merchant what we score them on.

04Still open

Three things we have not closed

Two of them are limits of the method rather than findings, and naming a limit is more useful than implying a defence we have not bought.

  • One dependency advisory, on an unreachable path

    An advisory against a tracing library we depend on. Pinning the fixed version breaks the build, because the experimental packages in that ecosystem import an export the fixed version removed. The code path is also off by default: no exporter endpoint is configured, so nothing parses the affected header until tracing is switched on. Moving that whole stack forward is the real fix and its own piece of work. The attempt and its failure are recorded in the repository so that nobody repeats them.

  • The receipt string is still the provider's word

    Our confirmation asks the provider whether a transaction succeeded. It does not tell us what the receipt number was. So a forged callback cannot invent a payment or change an amount, and could in principle supply a receipt string that matches nothing. Daily reconciliation files an exception for exactly that. A limit of the method, named rather than left for somebody to discover.

  • Edge limits are per address

    They stop one source being rude, cheaply and well. A flood from many addresses at once is a different purchase at a layer above this one. Saying so is more useful than implying a defence we have not bought.

05Not on this page

Where we stop being open

There is a line between telling you what we take seriously and handing somebody a map, and it is worth stating where we put it.

Current thresholds

Rate limits, size caps and timeouts exist and their numbers are not published. A published limit is a published budget for anyone working out how to stay under it.

The risk model

What we score a merchant on, and how we weight it, is excluded from our own API schema for the same reason. A fraud control you can read is a fraud control you can plan around.

Topology and paths

Which hosts, which callback paths, which internal services. None of it makes you safer to know and all of it narrows somebody's search.

Anything live and unfixed

If a finding were open and exploitable it would be fixed before it was written about, and the writing would come after. Nothing on this page is a live hole.

Found something?

Write to us with enough detail to reproduce it. We will confirm receipt, tell you what we think it is, and tell you when it is fixed. We will not threaten you, and we will credit you by name if you want that.

We are pre-launch and have no bounty programme yet. Saying that plainly seems better than implying one and disappointing somebody who did us a favour.