Cybersecurity · Risk management · Threat modelling · Backend
Fixing my own findings: a remediation plan, ranked by risk
My threat model of this site's contact form found four weaknesses. Not every finding deserves the same urgency. Here is how I ranked them by likelihood and impact, how I plan to fix each one, and why one of them I'm choosing to accept.
In my threat model of this site’s contact form, I ended with four weaknesses I had found in my own design. Finding problems is the easy half. The harder half is deciding what to fix first, how, and what not to fix at all.
This post is that second half: a small, real remediation plan.

How I ranked them
A simple way to compare risks is to ask two questions about each one:
- Likelihood: how probable is it that this weakness is actually exploited, or causes harm?
- Impact: if it happens, how bad is it, and for whom?
The matrix above is my own judgement, not a measurement. That is normal: most real risk assessments are informed judgement, written down so that others can challenge it. The value is in making the reasoning visible.
1. Messages kept forever: fix first
The risk. Every message sent through the form stays in the database indefinitely. Each one contains a name, an email address and whatever the person chose to tell me. If the database were ever exposed, through a leaked credential or a mistake of my own, everything ever sent would be exposed with it.
Why it ranks first. The impact falls on other people, who trusted me with their details. And the likelihood that some data store, somewhere, is eventually exposed over many years is not small. Data you don’t keep can’t leak.
The fix. A scheduled job that deletes messages older than a fixed period. Cloudflare Workers support cron triggers, so the same Worker that receives messages can run a daily DELETE FROM messages WHERE created_at < … query. Twelve months feels right: long enough to follow up a conversation, short enough to limit what a breach could reveal. This also brings the site closer to the storage-limitation principle in Ghana’s Data Protection Act, 2012 (Act 843).
2. 'unsafe-inline' in the script policy
The risk. The site’s Content Security Policy allows inline scripts. CSP is a safety net against cross-site scripting (XSS): if an attacker ever managed to inject a script into a page, a strict policy would stop it from running. With 'unsafe-inline', the net has a hole in exactly the wrong place.
Why it ranks second. The likelihood is low: the site is static and doesn’t display user-submitted content, so there is no obvious injection point today. But the impact of a successful XSS on every visitor would be high, and defence in depth means not relying on “there’s no bug today”.
The fix. Replace 'unsafe-inline' with a hash for each inline script. At build time, compute the SHA-256 hash of every inline script and list those hashes in the policy. The browser then runs only scripts whose exact contents I approved. Because the site is static, the hashes only change when I change a script, so this can be automated in the build.
3. A rate limit that can be raced
The risk. The limit of five messages per hour per IP is stored in Cloudflare KV, which is eventually consistent, and the code reads the counter and then writes it as two separate steps. A burst of requests sent in parallel can all read the same old count and slip slightly past the limit.
Why it ranks third. It is easy to trigger but the impact is small: a few extra spam messages that still have to pass the Turnstile bot check. For a contact form, an approximate limit is acceptable. It would not be acceptable for something like login attempts.
The fix. Move the counter to something with atomic updates: either Cloudflare’s built-in rate limiting for Workers, or a Durable Object, which processes requests for a given key one at a time. Either removes the race.
4. Shared IPs share one limit: accept, document, monitor
The risk. Many mobile networks place large numbers of users behind a small pool of shared public IP addresses. On such a network, one abusive user could use up the hourly limit for innocent people who share their address.
Why I’m accepting it. Every alternative has a cost. Limiting by email address is easy to evade with made-up addresses. Removing the IP limit would weaken protection against floods. Raising the limit a lot would let more spam through. Given that a blocked visitor can simply try again later or email me directly, the harm is small.
What “accepting” means. Accepting a risk is a legitimate decision, but only when it is conscious and recorded. So I’m writing it down here, with the reasoning, and I’ll revisit it if I ever see real visitors being blocked. If this were a service for thousands of mobile users, rather than a personal contact form, I would rank it much higher.
What this exercise taught me
Two lessons:
- Not every finding is urgent. Ranking by likelihood and impact stopped me from spending my time on the most interesting technical fix (the rate-limit race) before the one that actually protects people (deleting old data).
- “Fix”, “mitigate” and “accept” are all valid outcomes, as long as the choice is deliberate. A security plan that claims to fix everything is usually one that hasn’t looked closely enough.
I’ll update this post as each fix ships.
Disagree with my ranking? I’d like to hear why.
