Leash

Fintech + Cybersecurity track

Money can't move until the checkout page matches what the group agreed to. Signed, sandboxed, and if anything is different, everyone is refunded before a single click happens.

Inspiration

Five friends agree on an Airbnb in a group chat. One person puts $1,000 on their card and spends the next week chasing Venmo requests. And in the gap between "yes, this one" and the card actually getting charged, nobody checks anything. The price can change. The dates can shift. A scam listing can say "pay the host directly on Zelle." Splitwise tracks who owes who. Venmo moves the money. Airbnb charges the card. Not one of them asks whether the thing being paid for is still the thing everyone said yes to.

So that's what we built. Not a splitting app. A payment that refuses to go through unless it can prove it's the same deal the group approved.

What it does

Leash runs inside a normal iMessage group. Add its number, drop the listing link and dates, and it DMs every person a private link for their exact share. Money collects in a bank account created just for that booking.

Around the money, three things happen:

  1. Before anyone pays, Leash opens the listing in an isolated sandbox and builds an initial token: listing ID, check-in, check-out, guests, currency, total. Those six fields are signed with HMAC and a server secret. That's the snapshot of what the group agreed to.
  2. After everyone pays, a second, fresh sandbox opens the checkout page and builds a final token the exact same way.
  3. Compare. Signatures first, then every field. Match: the pooled funds release and the browser gets permission to click. Mismatch: the click never fires and everyone who paid is refunded automatically.

You watch the actual browser do it, live, cursor and all.

How we built it

Node, Express, Playwright, SQLite. Four modules, four people, one architecture doc, merged Saturday night.

Tokens (fraud/). A cheerio parser pulls the six fields off the page. Listing ID comes from the URL or canonical link, never page text, so a page can't claim to be a different home. Fields are serialized in fixed order and HMAC-SHA256 signed; verification goes through timingSafeEqual. Title and host are left out of the signature on purpose, so a page that changes its wording doesn't break a legit approval. compareTokens returns the signature result first, then field diffs in canonical order with plain reasons like total price changed from 900 to 1240.

Listing scanner (fraud/signals.js). Strips scripts, templates, hidden and aria-hidden nodes first, because Airbnb's hydration bundle literally contains the string "no reviews" and we did not want that counted as evidence. Then it reads sentence by sentence for a real request to pay off-platform: wire, Zelle, Venmo, crypto, gift cards, or a non-Airbnb link next to the word "pay." Negations are handled, so "never pay outside Airbnb" doesn't trip it. A hit blocks before anyone is asked for money. The scanner can only close the gate, never open it.

Sandbox (daytona/). Every browser pass runs in a throwaway Daytona sandbox that's deleted afterward. GET only, HTTPS only, domain allowlist. The URL validator rejects IP literals, internal hostnames, credentials, odd ports, action paths like /reserve, and query keys that look like tokens or redirects. DNS is resolved once and the address pinned to the socket, so a hostname that flips to a private IP mid-request goes nowhere. 2 MB page cap, no redirect following. Everything the page says comes back typed as untrusted_page_claims with fixed finding codes, never raw text. Injection patterns, zero-width characters, base64 blobs, meta-refresh, <base> overrides, and payment-looking links that leave the origin all drop the descriptive fields. safeToProceed is hard-coded false. The sandbox never gets a vote on spending.

Live stream. The Playwright worker writes JPEG frames inside the sandbox whenever the page changes. The backend polls them while the command is still running and pushes them over server-sent events. No controls on the viewer side, just a window.

Bank and payments (nessie/, payments/, stripe/). One Capital One Nessie account per booking, UUID in the nickname. Everything is integer cents, because Nessie silently truncates decimals. The balance field is never trusted: every deposit is read back and checked against its reference, amount, and status before it counts. Duplicate webhooks can't double-count. No full funding in 30 minutes, everyone's refunded. Refunds use stable idempotency keys, poll pending status, and retry; an unknown debit outcome goes to a reconciliation state instead of a blind retry. Stripe test checkout is wired with raw-body webhook signature checks.

Pipeline (app/). finalize asserts the pooled balance equals the initial token's total, runs the second pass with a commit callback, compares tokens, and only on OK calls the bank spend and gives the browser the green light to click. Incoming iMessage IDs are persisted before handling, so replayed events are ignored across restarts. A process lock allows one running instance. The only public routes are /pay and /watch; every booking and dashboard endpoint was removed and tests assert they 404.

Tests. 182 across 27 files, including a full browser test that runs a normal booking, a price-changed booking, and a merchant-rerouted booking through two fresh sandboxes each.

Challenges

  • Nessie math. Truncated decimals and deposits that didn't update the balance. Integer cents plus read-back of every deposit fixed it.
  • Streaming from inside a sandbox. You can't wait for the command to finish if you want a live view, so we poll frame files while Playwright is still running.
  • Making the page just data. Early scanner versions flagged legit listings because hidden hydration strings leaked into "visible text." Stripping hidden DOM and reading review counts only from real review elements got false positives to zero on our fixtures.

What we learned

A scanner can say something looks sketchy. Only a signed comparison can say "this is not what you approved." Never trust a balance field, read the ledger. Isolation is only real if the sandboxed thing has no path to the thing that spends. And a live demo of an attack getting blocked beats any slide about security.

What's next

Real payment rails (Stripe is already in test mode). More booking sites. Pull the token gate out as a standalone library so anything that spends money on someone's behalf can prove it paid for what was approved. The only thing between Leash and real Airbnb bookings is Airbnb's permission. The code is there.

Built with

Node.js, Express, Playwright, Daytona, Photon iMessage API, Capital One Nessie, Stripe, cheerio, SQLite, HMAC-SHA256, server-sent events

Built With

Share this project:

Updates

Submission history