CybersecurityFeatured

Security HTTP Headers Checker — CLI, API & Web Grader

Command-line tool grading a site's HTTP security headers: one request, six headers, a 0-100 score and an A-F grade. Follows redirects to grade the final URL, tells a missing header from a present-but-inert one, and returns a CI-usable exit code - same engine exposed as a FastAPI web app on Render.

2026
Completed (2026)
1 member

Technologies Used

PythonhttpxFastAPIuvicorntyperrichpytestrespxREST APIRenderCI/CD

A command-line tool that grades the HTTP security headers of a website. One request, six headers evaluated, a score from 0 to 100 and a letter grade A–F. The same engine is exposed as a CLI, a JSON API and a web page.

🎯 Two rules that shape the tool

It grades the final URL. Redirects are followed, and the verdict applies to the page the browser actually lands on — not to the address that was typed. A site redirecting HTTP to HTTPS would otherwise be scored on headers nobody ever receives.

A header being present is not a header being effective. The status is a three-value verdict — ok, weak, missing — so a Strict-Transport-Security: max-age=0, which disables HSTS while looking like a correct configuration, is reported as weak and scores half the weight.

📊 The scoring rubric

| Header | Weight | ok when | |---|---:|---| | Strict-Transport-Security | 30 | max-age ≥ 6 months | | Content-Security-Policy | 30 | policy present and restrictive | | X-Frame-Options | 15 | DENY or SAMEORIGIN | | X-Content-Type-Options | 15 | nosniff | | Referrer-Policy | 5 | strict policy | | Permissions-Policy | 5 | present and non-empty |

Weights total 100. Grades: ≥ 90 A · ≥ 80 B · ≥ 70 C · ≥ 60 D · otherwise F

🚦 Usable as a CI quality gate

The exit code carries the verdict — 0 for an A or B, 1 for a C or D, 2 for an F, a network error or an invalid URL:

shhc https://my-site.com || echo "insufficient headers"

Output comes in three shapes: a colored Rich table, --quiet for a single line, --json for machines.

🏗️ Architecture

A single Finding dataclass travels between layers — rules.py produces a list of them, scoring.py consumes it, render.py displays it. No layer knows the others.

cli.py / api.py ... orchestration, exit codes, HTTP routes
       |
   fetch.py ...... 1 request -> (final URL, lowercased headers)
       |
   rules.py ...... 6 rules -> list[Finding]
       |
   scoring.py .... sum of points -> score + grade
       |
   render.py ..... table + grade panel + recommendations

Pure modules first. rules.py and scoring.py do neither network nor display: a function takes a dictionary and returns an object. They are 311 of the package's 548 lines, and they are tested without mocking anything — hence 80 tests running in seconds.

Normalization in a single place. HTTP headers being case-insensitive, fetch.py lowercases every key on the way out of the request, so no rule downstream has to test several spellings.

🌐 Web version and API

api.py reuses fetch, rules and scoring exactly like the CLI — the business logic is not duplicated. FastAPI serves the form page, GET /api/check?url=… for the JSON report, /api/health as a liveness probe, and generates the OpenAPI documentation at /docs. Deployed on Render.com through a render.yaml blueprint.

Protecting the public version

A service that fetches a visitor-supplied URL is a door into the host's internal network. Two guardrails:

  • Anti-SSRF — the domain is resolved before the request and every non-public address is refused: loopback, private ranges, link-local. Without it, a visitor could make the server query http://169.254.169.254/, the cloud metadata endpoint that often holds credentials.
  • Rate limiting — 20 scans per IP per minute, sliding window. Every call triggers an outbound request; without a limit the service becomes a relay to hammer a third party.

Known limitation, documented rather than hidden: the guard resolves the name, then httpx resolves it again — a window for DNS rebinding. Closing it requires resolving once and connecting to the validated IP.

🧰 Engineering choices

httpx over requests. Redirect following must be a visible choice, not an implicit behavior — it is the core of the "grade the final URL" rule. httpx requires follow_redirects=True explicitly and enforces an explicit timeout, avoiding the classic omission that leaves a program hanging forever.

respx for the tests. It replaces the httpx transport layer and serves responses declared in advance: the whole HTTP code path really runs, redirects included, only the socket is short-circuited. An unplanned network call fails the test instead of reaching the internet — so the suite is neither slow nor flaky.

ASCII-only rendering. The Windows console commonly runs code page 850, and CI logs are rarely UTF-8; em dashes and bullets would render as ?. Colors, on the other hand, work everywhere.

Stack: Python 3.11, httpx, rich, typer, FastAPI, uvicorn, pytest, respx.

Challenges

  • Grading the page the browser actually lands on, not the URL typed: a site redirecting HTTP to HTTPS would otherwise be scored on headers nobody ever receives
  • A header being present says nothing about it being effective — Strict-Transport-Security: max-age=0 disables HSTS while looking like a correct configuration
  • Exposing the same engine as a CLI, a JSON API and a web page without duplicating the scoring logic in three places
  • A public service fetching a visitor-supplied URL is a doorway into the host's internal network (SSRF) and a free relay to hammer third-party sites
  • Testing a network layer without depending on real sites, whose configuration can change and break a test the code never touched
  • Rendering readable tables in Windows consoles on code page 850 and in CI logs that are rarely UTF-8

Solutions

  • Used httpx with an explicit follow_redirects=True and an explicit timeout, then graded the final URL returned by the response
  • Made the status a three-value verdict — ok / weak / missing — where weak scores half the weight, so an inert header is visibly distinguished from a correct one
  • Kept rules.py and scoring.py pure (no network, no display) so cli.py and api.py are thin facades over the same functions: 311 of the package's 548 lines are pure and testable with no mocking
  • Added shhc/guards.py: the domain is resolved before the request and every non-public address is refused (loopback, private ranges, link-local, cloud metadata at 169.254.169.254), plus a 20-scans-per-IP-per-minute sliding window
  • Used respx to replace the httpx transport layer: the whole HTTP code path really runs, only the socket is short-circuited, and an unplanned network call fails the test
  • Restricted all rendering to ASCII while keeping Rich colors, which work on every terminal

Outcomes

  • Six headers scored on a 100-point weighted rubric (HSTS 30, CSP 30, X-Frame-Options 15, X-Content-Type-Options 15, Referrer-Policy 5, Permissions-Policy 5) with an A–F grade
  • CI-usable exit codes: 0 for A/B, 1 for C/D, 2 for F, network error or invalid URL — the tool works as a quality gate in a single shell line
  • Three interfaces over one engine: CLI (plain, --quiet, --json), FastAPI JSON API with generated OpenAPI docs, and a dependency-free web page
  • 80 tests running in seconds with zero network access, covering the rubric invariants, the grade boundaries (90 → A, 89 → B), the SSRF refusals and the 400/429/502 API codes
  • Deployed publicly on Render.com via a render.yaml blueprint, with an anti-SSRF guard and per-IP rate limiting
  • Documented a known DNS-rebinding limitation rather than hiding it: the guard resolves the name and httpx resolves it again