Staying hyper-vigilant doesn't come with a calendar reminder that tells you when to renew it. The May audit ended with a clean bill and a note: "next scheduled review, August 8, 2026." I didn't wait for August. Two months of shipping later, I asked for a fresh scan against the same categories that audit used, and the result was smaller than the first one but not nothing: 8 findings, three of them in code that didn't exist in May.
That's the actual headline here, more than any individual bug. The first audit covered the blog as it stood in early May. Since then we shipped an MCP server (16 tools, bearer-token auth, its own rate limiter), a Slack /todo integration, and a shareable-snippet image generator for code blocks and tables. None of that existed when the last audit ran, which means none of it had ever been looked at with a security lens. A codebase doesn't need to get worse to need a second look. It just needs to get bigger. Friday's post named this the hyper-vigilance tax and paid it on the UX axis: three small bugs, each hiding in a corner some check didn't cover. This post pays the same tax on the other axis, the one that doesn't announce itself in a screenshot and doesn't get noticed by accident. Different axis, same bill.
What the Scan Covered
Same shape as May: auth and middleware, every API route, GitHub Actions workflows in both repos, npm audit, and a secrets/PII grep across both repos. The difference this time was scope creep in a good way β three routes that simply didn't exist during the first pass got the same scrutiny as the routes that did.
Eight findings, roughly matching the severity spread from last time:
- 1 high: a transitive dependency with five stacked advisories
- 5 moderate: the rest of a dependency chain, plus two independent route-level gaps
- 2 low: a dev-only dependency advisory, and a documented (not code) tradeoff
Phase 1: Dependency Bumps
npm audit on a real npm install (not a stale lockfile check) turned up 8 vulnerabilities: 1 high, 6 moderate, 1 low. The high was hono@4.12.22, pulled in transitively through @modelcontextprotocol/sdk, which the MCP server depends on. Five stacked advisories on that one package: a CORS-reflects-any-origin-with-credentials bug, a body-limit bypass on Lambda-style deployments, a path-traversal issue in serve-static on Windows, and two adapter bugs that silently drop cookies or headers.
npm audit fix β the non-forcing kind β resolved all of it except one chain: hono jumped to 4.12.30 (patched), and brace-expansion, js-yaml (including the copy that gray-matter depends on, which matters because gray-matter parses every post's frontmatter in production), and @babel/core all resolved within their existing semver ranges.
What's left is the exact same false positive the May audit documented: postcss <8.5.10, bundled inside next itself, not the @tailwindcss/postcss copy (already patched). npm audit fix --force proposes downgrading next to 9.3.3 to fix it β a multi-year regression that would break considerably more than it fixes. I checked whether a newer Next.js release had bumped its bundled postcss; even the 16.3.0 preview builds haven't. Same conclusion as May: accepted, monitored risk, no fix available yet that isn't worse than the bug.
One commit. tsc --noEmit clean afterward.
Phase 2: Three Independent Route Gaps
None of these three depend on each other, so they went into one batched commit β the same logic the last audit used for its Phase 2.
The MCP route's rate limiter was in-memory. const buckets = new Map<string, {...}>(), keyed by IP, capped at 120 requests/minute. That looks reasonable until you remember Vercel runs serverless functions across multiple instances. Each cold start gets its own empty Map. A client that happens to land on five different instances effectively gets five times the stated limit β the number on the tin was never the number in practice. Swapped it for the same Redis-backed limiter the login and analytics routes already use, which is shared across every instance regardless of which one handles a given request.
/api/share-image had no bounds and no rate limit. This route is intentionally public β it's called client-side to render shareable images of code blocks and tables for social sharing, so it can't sit behind the admin-session middleware. But it accepted an arbitrary-length content string and computed the output image's height with no ceiling; only width was capped. That's a real rendering-cost DoS sitting in the open: no authentication, no throttling, no size limit, on an endpoint whose cost scales with attacker-supplied input. Added a per-IP rate limit (20/minute via the same Redis limiter), a 20,000-character content cap, a 200-row table cap, and a 4,000px height ceiling to match the existing width cap.
Both Dev.to syndication routes skipped slug sanitization. content/posts/${slug}.mdx with a raw, unsanitized slug straight from the request body, feeding into a GitHub Contents API path. Every other route touching the same file space β post editing, image upload, every MCP tool β validates the slug first. These two just got missed, probably because they were added after the pattern was established elsewhere and nobody thought to check whether they'd inherited it. Since three separate files each had their own copy-pasted sanitizeSlug(), and a fourth and fifth were missing it entirely, I pulled the function into src/lib/slug.ts once and pointed all five call sites at it. Low impact today, since both routes sit behind admin-session middleware, but it's the kind of inconsistency that becomes a real path-traversal bug the moment the trust boundary shifts even slightly.
Verified with tsc --noEmit, eslint on every changed file, and a full production build. One unrelated finding surfaced during lint: share-image/route.tsx has 10 pre-existing lint errors (JSX constructed inside a try/catch, which ESLint's react-hooks/error-boundaries rule flags because React doesn't actually catch render errors that way). Confirmed via git stash that they predate this change entirely β not something to silently fix inside a security commit, so it's noted here and left for its own pass.
Phase 3: Finishing What Report-Only Started
The May audit's Phase 2 shipped Content-Security-Policy in Report-Only mode on purpose, with a plan to observe for about a week, then flip to enforcing. That flip never happened. It sat in Report-Only for two months.
Here's the part that made this an easy call: there's no report-to or report-uri directive configured anywhere in the policy. Report-Only mode without a reporting endpoint doesn't collect anything except what shows up in an individual visitor's own browser console β which nobody but the site owner would ever open, and even then only by accident. The "observe for a week" plan had no mechanism to observe anything. Two months of waiting produced exactly as much signal as two minutes would have.
So: flipped the header from Content-Security-Policy-Report-Only to Content-Security-Policy, same directive set, unchanged since May. Rather than trust the diff, I ran a full production build, started the built server, and curl -I'd the homepage to confirm the actual response header. It came back exactly as expected β enforcing, same values. This one got its own commit, isolated from Phase 2, because it's a global behavior change with real blast radius if a directive gap exists that Report-Only never had the means to catch. Easy to revert on its own if something breaks that two months of silent Report-Only never revealed.
What Got Deferred
One item, carried forward rather than fixed: the rate limiter (shared across login, analytics, MCP, and now share-image) fails open if Upstash Redis is unreachable or misconfigured. That's a deliberate, documented tradeoff from when the limiter was first built β a preview environment without Redis configured shouldn't get locked out of its own login page. It's still the right tradeoff. But it means a silent Redis misconfiguration in production would silently remove every rate limit on the site with no alert. Not a code fix; a monitoring gap. Noted for whenever alerting gets built out, the same way the last audit deferred CSP nonces and build-time markdown rendering with reasons instead of silently dropping them.
What This Says About Auditing Cadence
The real lesson isn't in any individual bug. It's that "audit the codebase" isn't a checkbox you tick once. The MCP server, the Slack integration, and the share-image route all shipped in the two months between audits, each one reasonably reviewed on its own merits at the time, and none of them got the systematic security pass the rest of the codebase got in May β because that pass had already happened before they existed.
A quarterly audit cadence, which is what the May report suggested, assumes the codebase's rate of change is roughly constant. For a one-person-plus-agent blog shipping new integrations every few weeks, three months is long enough for entire new subsystems to exist unaudited. That's the same structural blind spot a very different audit found in this blog's SEO and AEO tooling months ago: a checker that only looks where it was built to look will miss whatever got added after it was written. The actual cadence that matches this project isn't a calendar date. It's "whenever a new API route ships that talks to the outside world," which in practice has been happening faster than the calendar suggested.
By the Numbers
- 8 findings this pass, versus 90+ raw findings (deduplicated to 15) in May β this audit had one scanner instead of three, and a much smaller diff to cover
- 3 of 8 findings were in code that didn't exist during the May audit (MCP rate limiter, share-image bounds, and the Dev.to slug gap, added alongside newer syndication tooling)
-
5 stacked advisories on a single transitive dependency (
hono), resolved by one non-forcingnpm audit fix -
8 β 4 vulnerabilities after Phase 1, all four remaining from the same root cause (
postcssbundled insidenext) - 3 phases, 3 commits, 1 repo β no content-repo changes this round
- 0 new dependencies added to fix anything
- ~1 hour end to end, versus 4 hours for the May audit's ten commits across two repos
- 2 months a CSP policy sat in Report-Only mode with no reporting endpoint configured, collecting zero actual violation data
-
1 shared
sanitizeSlug()replacing 3 copy-pasted versions and closing 2 missing ones -
10 pre-existing lint errors found, confirmed unrelated via
git stash, and left alone rather than folded into a security commit - 1 deferred item, carried forward with a reason, not silently dropped













