# keeping bots out without inviting google in

· #privacy #self-hosting #matrix

salix@host:~/posts/privacy-friendly-captcha

If you tried to register on salix.host, you probably noticed the little driving game. You have to survive eighteen seconds of traffic before you get a registration token. It is a bit silly, but there is a reason it exists.

Running an open Matrix server means I need some way to keep automatic signups out. If registration is completely open, bots will find it sooner or later. They can use the accounts for spam, scams, or things I really do not want hosted on my server. Closing registration would solve that, but then normal people cannot join either, which misses the point of running a public server.

The normal answer is adding a CAPTCHA. Usually that means placing a box from Google, Cloudflare, or another large company on the page. It works, but it also means every person who wants an account has to talk to that company first. Their browser sends another request, their IP address goes somewhere else, and sometimes they get asked to identify traffic lights for five minutes because some invisible score did not like them.

That felt a bit wrong for a privacy-focused Matrix server. “We care about your privacy, now please report to Google before entering” is not a very convincing welcome message.

So I made my own.

The first version

The registration system was already using proof of work. The server gives your browser a small SHA-256 puzzle, and the browser tries numbers until it finds the correct one. It takes a few seconds on a normal device, but doing it thousands of times becomes expensive for a spammer.

Proof of work is useful, but by itself it is not enough. A real visitor waits a few seconds once. A bot can solve puzzles all day, especially if the accounts are worth something. It raises the cost, which is good, but it does not really tell humans and scripts apart.

I wanted a second check that needed some interaction, without collecting personal information. For some reason my answer to this was a five-lane road full of badly driven pixel cars and trucks.

The idea was inspired by CAPTCHA Hell, an upcoming game about someone trying to get through a pile of increasingly ridiculous CAPTCHAs. I saw the driving CAPTCHA in it and thought it was funny. Then I started wondering if something like that could work as a real CAPTCHA too. So credit for the strange idea belongs there. I just made my own version and probably took it much too seriously.

You control a small car with a slider. Other vehicles come down the road, some change lanes, and you need to avoid them for eighteen seconds. The car has a bit of momentum, so you cannot instantly jump from one side to the other. If you crash, you can try the same road again.

It is not a scientific test for being human. It is just an annoying enough task that basic signup scripts cannot press a button and receive unlimited accounts.

The browser is not trusted

The obvious problem with a homemade CAPTCHA is that all browser code is visible. Anyone can open the developer tools, read the JavaScript, and change it. If the page simply sent I won to the server, a bot would also send I won, probably much faster than me.

The server therefore does not trust the result reported by the browser. During the game, the browser records the position of the steering slider on every game tick. That list of steering inputs is what gets submitted at the end.

The server then runs the same movement physics again. It starts the car in the middle, applies every steering input, and checks the calculated position against the traffic at that moment. If the replay hits a vehicle, the run is rejected. The browser never gets to decide where the car really was.

There was another issue. If I sent all traffic data as nice structured JSON, a script could read every vehicle position and calculate a perfect route. Instead, the server creates the traffic and renders it into a PNG image. The browser receives the pixels needed to show the road, but not a convenient list saying “truck in lane three at tick 120”.

Of course pixels can still be analysed. I am not claiming I invented some unbreakable bot detector here. But analysing an animated pixel road and producing valid steering input is quite a bit more work than calling an API with the right JSON fields. For a small hobby server, that difference matters.

Making sure the game is fair

Random traffic sounds fun until the game generates a wall of trucks across every lane.

Before sending a challenge, the server checks that the road can actually be completed with the same speed limits as the player’s car. It works backwards through the game and checks which positions still have an escape route. It also rejects roads where sitting in the centre and doing nothing would pass, because then it would be a rather useless driving test.

This part took more work than I expected. Generating random cars is easy. Generating random cars that look unpredictable, require steering, and never put a normal player into an impossible trap is less easy.

Lane-changing cars even blink before moving. That is partly to be fair, and partly because apparently I care about the driving manners of CAPTCHA traffic now.

Several small locks instead of one big lock

The driving game is only one layer. While you are driving, the browser solves the proof-of-work puzzle in the background. The challenge is signed by the server, tied to the visitor’s IP rate-limit bucket, expires after fifteen minutes, and can only be used once. Finishing in less than eighteen seconds is also rejected, for fairly obvious reasons.

There are limits on how many challenges and tokens one address can request, plus a global limit in case somebody arrives with a large number of addresses. IPv6 needs a little care here, because one device can have a huge amount of addresses, so I count them by network instead of pretending every address is a different visitor.

After everything passes, the registration token is valid for ten minutes and can create one account. That is enough time for a person to paste it into Element, but not very useful to someone collecting tokens for later.

None of these protections is perfect alone. Together they make abuse inconvenient enough without making registration terrible for normal people. At least that is the balance I am trying to find.

Keeping the dangerous secret somewhere else

The public dispenser needs permission to hand out registration tokens, but I did not want the public-facing process to hold the Synapse administrator token. If there is a bug in the dispenser, that secret would give an attacker much more power than they should ever get.

So token creation is split into a second, very small service. The public process verifies the puzzle, the driving run, and the rate limits. Only after all of that it asks the second service to mint one token through a local Unix socket.

That second service understands exactly one command: mint. It has its own global rate limit and returns a short-lived, single-use token. It does not listen on a public network port. The public process can talk to it, but cannot read the administrator token it holds.

Process separation is not exciting to show in a screenshot, but it might be the part I care most about. A public web service will always have some attack surface. Giving it fewer secrets makes a mistake less disastrous.

What it does not solve

I know a driving game has accessibility problems. It needs vision and reasonably precise input, and it might be difficult or impossible for some people. That is the biggest weakness of this design. At the moment, contacting me for an account is the fallback, but that is not as good as having an accessible option built into the page. I still need a better answer for this.

It also will not stop a determined attacker forever. Someone could build computer vision for it, automate a browser, or simply pay humans to solve challenges. CAPTCHAs are an arms race and a small custom one does not magically escape that.

What it does stop is cheap, generic automation. There is no standard CAPTCHA-solving service already prepared for this exact strange road. There are no cookies, no fingerprinting, and no third-party script learning who visited the registration page. The temporary IP limits live on my server, where the signup is happening anyway.

That is good enough for my current threat model. salix.host is a small homeserver, not a bank and not the next giant social network. I do not need to defeat every bot on earth. I need to make mass signup more trouble than this server is worth abusing.

And if people get a tiny pixel racing game out of it, honestly, that is nicer than clicking nine blurry bicycles.

~$