DEV Community

Cover image for What I Keep Finding When I Scan Small U.S. Municipal Websites (And How To Fix It In Under An Hour)
CivicMeshFlow
CivicMeshFlow

Posted on

What I Keep Finding When I Scan Small U.S. Municipal Websites (And How To Fix It In Under An Hour)

I’ve spent the last few months poking at public websites that belong to small U.S. towns, school districts and counties. Not as an attacker, but as the guy who built a small open‑source scanner called CivicMeshFlow.

Most of these sites sit on old PHP stacks and very tired CMS installs. Nobody gets promoted for "fixing security headers", and budgets are usually eaten by whatever crisis is happening this month.

Still, these sites handle real people’s data. So I started running systematic scans, keeping notes as I went.

Very quickly a pattern showed up: different vendors, different designs, but the same security mistakes repeating everywhere.

This post is basically my field notes. If you’re responsible for a public‑facing municipal site and only have an hour here and there, this should give you a realistic hardening plan.


How I’ve Been Scanning (Nothing Fancy)

CivicMeshFlow is a Laravel + Python thing I run on my own server. I point it at a domain, it follows redirects, inspects response headers, hits a few common paths and then spits out a report with grades and a PDF.

Even with the tool, I still fall back to the terminal a lot. A typical quick check looks like this:

# Follow redirects and inspect final headers
curl -sSL -D - https://example.gov/ -o /dev/null | sed -n '1,40p'

# Make sure HTTP is strictly redirected to HTTPS
curl -I http://example.gov/ | sed -n '1,20p'
Enter fullscreen mode Exit fullscreen mode

If Cloudflare is in front, I hit both the apex and www, and I always try plain http:// just to see how that very first hop behaves.

On one of the first counties I tested, the naked http:// version quietly served an old login form over HTTP with no redirect. From the outside it looked like a normal site; from the inside it was a MITM buffet.

I’m not doing anything you’d call a penetration test. The goal here is humbler: don’t leave the obviously‑bad stuff online for years.


1. HTTPS Is There… But HSTS Is Nowhere

Almost every site I looked at does redirect to HTTPS. That’s the good news.

The bad news: a surprising number never send a Strict-Transport-Security header. That means the first visit can still be downgraded to HTTP by anyone sitting on the same Wi‑Fi.

If you’re on Nginx, the fix is literally one line:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
Enter fullscreen mode Exit fullscreen mode

On Apache:

Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Enter fullscreen mode Exit fullscreen mode

If flipping that switch makes you nervous, start with a smaller value:

add_header Strict-Transport-Security "max-age=86400" always;
Enter fullscreen mode Exit fullscreen mode

Let it run for a day, watch your logs, then move to a full year and add includeSubDomains; preload when you’re confident.

On my first batch of ~30 municipal sites, only a small handful had HSTS configured correctly end‑to‑end. Everybody else just hoped the redirect would be enough.


2. No CSP At All (Or Something That Might As Well Be None)

The next recurring issue is Content Security Policy, or rather the lack of it.

These sites usually have a long history: different contractors, different plugins, a few widgets that nobody remembers adding. The end result is a soup of inline scripts and external JavaScript from half the internet.

Without a CSP, one compromised third‑party script can silently hijack the whole page for every resident who visits it. That’s a pretty big risk for a town hall announcing tax deadlines or school portals.

Here’s a conservative starter policy that already shuts a lot of doors:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com;
  style-src  'self' 'unsafe-inline';
  img-src    'self' data:;
  connect-src 'self';
  frame-ancestors 'none';
Enter fullscreen mode Exit fullscreen mode

What I usually recommend in practice:

  1. Ship this as Report-Only first.
  2. Let it run for a week while you watch the console and logs.
  3. Add hashes or nonces for the inline scripts that truly need to stay.
  4. Then flip it into enforcing mode.

It’s not glamorous work, but the difference between "no CSP" and "baseline CSP" is huge.


3. Leaky Files and Debug Leftovers

This one still surprises me, but I see it enough that CivicMeshFlow has special checks just for it.

Things I’ve found on public municipal domains:

  • .env files readable straight from the browser
  • .git/ directories with commit history
  • phpinfo.php on test subdomains that were never actually decommissioned
  • editor backup files, old/ directories, and the classic backup.zip sitting in web root

Most of this can be fixed with two short snippets.

Nginx:

location ~ /\.(git|svn|hg|env) {
  deny all;
}

location = /phpinfo.php {
  deny all;
}
Enter fullscreen mode Exit fullscreen mode

Apache:

<FilesMatch "^(\.git|\.env|phpinfo\.php)$">
  Require all denied
</FilesMatch>
Enter fullscreen mode Exit fullscreen mode

If you’re behind Cloudflare anyway, use it as an extra safety net:

  • Field: URI Path
  • Operator: contains
  • Value: .env
  • Action: Block

I do similar rules for /backup.zip and other “temporary” artifacts that, in reality, live forever.


4. Session Cookies That Trust Everyone

Another pattern that keeps showing up: session cookies without Secure, HttpOnly or SameSite.

On PHP you can centralize this change and not hunt through every call to setcookie:

session_set_cookie_params([
  'secure'   => true,
  'httponly' => true,
  'samesite' => 'Lax', // or 'Strict' if you never rely on cross-site flows
]);
Enter fullscreen mode Exit fullscreen mode

While you’re there, add the usual defensive headers:

X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer-when-downgrade
Permissions-Policy: geolocation=(), microphone=(), camera=()
Enter fullscreen mode Exit fullscreen mode

None of this will make headlines, but it removes a lot of trivial angles for abuse.


5. Client Libraries From Another Era

The last recurring theme is JavaScript and CSS from a very different time.

It’s common to see jQuery 1.x or 2.x, very old Bootstrap, and plugin bundles that haven’t been touched in a decade. I get why: upgrading feels risky, nobody wants to be the person who broke the public website before a council meeting.

My rule of thumb when I talk to local IT folks:

  • Upgrade the core library first, to a maintained version.
  • Keep the scary plugins isolated and behind extra testing.
  • Do a small, controlled maintenance window and be ready to roll back.

I’ve broken layouts by jumping two Bootstrap majors in one go. It wasn’t fun. But staying on vulnerable versions forever isn’t great either, especially for sites that citizens rely on daily.


A Realistic 60‑Minute Hardening Plan

If you only have an hour this month to work on a municipal site, here’s how I would spend it.

0–10 minutes: HSTS

  • Add HSTS with a small max-age.
  • Test http://https:// on apex and www.
  • Once things look stable, move to a full year and include subdomains.

10–25 minutes: CSP

  • Deploy a Report‑Only CSP like the starter above.
  • Check your logs and the browser console for violations.
  • Allow the analytics and scripts that you truly depend on.

25–35 minutes: Kill the obvious leaks

  • Block .env, .git/, phpinfo.php, directory listings.
  • Verify with curl that they now respond with 403 or 404.

35–45 minutes: Cookies and headers

  • Tighten your session cookie settings.
  • Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy.

45–60 minutes: Update the worst offenders

  • Identify very old jQuery/Bootstrap.
  • Replace them with supported versions.
  • Smoke‑test login, forms, admin and any public task flows.

Is your site “unhackable” after this? Of course not. But the bar for exploitation is now higher than “open Wi‑Fi and a browser”.


Where CivicMeshFlow Helps

This whole checklist grew out of me being tired of doing the same manual checks over and over.

CivicMeshFlow tries to automate the boring part:

  • It grades your header posture.
  • It flags risky endpoints and obvious leak patterns.
  • It groups recommendations into critical, important and optional.
  • It exports a timestamped PDF you can attach to tickets or internal reports.

The project is open‑source, and my goal is to keep it free for small public‑sector organizations in the U.S. If that’s you, I’m happy to run a couple of domains and talk through the findings.


About Me

I’m Nick Tkachenko, a senior PHP/Laravel developer based in Chicago and the person behind CivicMeshFlow, an open‑source platform for automatic auditing of vulnerable web services used by small U.S. government organizations.

If you’re curious about the project, you can find more details at: https://civicmeshflow.com

Top comments (0)