Skip to content

Fix unauthenticated OTA/control endpoints, CSRF, and stored XSS - #11

Open
cesar-xyz wants to merge 1 commit into
M-Abozaid:mainfrom
cesar-xyz:fix/auth-csrf-xss
Open

Fix unauthenticated OTA/control endpoints, CSRF, and stored XSS#11
cesar-xyz wants to merge 1 commit into
M-Abozaid:mainfrom
cesar-xyz:fix/auth-csrf-xss

Conversation

@cesar-xyz

Copy link
Copy Markdown

Summary

Every state-changing and OTA endpoint (/ban, /addblock, /unblock, /forgetwifi, /upload, /update, /setupdate, /fetchnow) had zero authentication, and ArduinoOTA had no password set. Anyone who could reach the device on the LAN could reflash it with arbitrary firmware or rewrite the blocklist with no credentials at all — worth fixing given the device sits in the path of every DNS query on the network. There was also a stored-XSS in the dashboard via custom blocklist domain names.

Endpoint Before After
/ban, /addblock, /unblock, /forgetwifi, /setupdate, /fetchnow open Basic Auth + CSRF header required
/upload (blocklist OTA), /update (firmware OTA) open Basic Auth + CSRF header required (checked in the upload callback)
ArduinoOTA (network flashing) no password OTA_PASS required
Dashboard custom-domain rendering raw innerHTML interpolation HTML-escaped

What's in this PR

  • Auth: WEB_USER/WEB_PASS (new secrets.h fields, still gitignored) gate every mutating endpoint via web.authenticate()/web.requestAuthentication(). ArduinoOTA.setPassword(OTA_PASS) added.
  • CSRF: Basic Auth alone doesn't stop CSRF here — these are GET endpoints with side effects, and browsers auto-attach cached Basic Auth credentials to any subsequent request to an already-authenticated origin, including one triggered by a totally unrelated page the same browser visits later (e.g. a bare <img src="http://c3adblock.local/forgetwifi">, no JS needed). That defeats the "LAN attacker" threat model entirely — the attacker doesn't need network access, just to get the victim's browser to fire one request. A plain <img>/auto-submitted <form> CSRF can't attach a custom header, only same-origin fetch() can, so every mutating request now also requires X-Requested-With: c3-adblock. This is why /forgetwifi is no longer a bare URL you can just visit — it's now a Forget WiFi button on the dashboard.
  • Upload-callback auth: for /upload and /update, the WebServer library's "done" handler (where you'd normally send a 401) fires after the multipart body is already parsed, so checking auth there is too late to prevent a partial write. Auth+CSRF are instead checked inside UPLOAD_FILE_START, storing the result in a bool (upAuthOk/fwAuthOk) that's freshly recomputed on every new upload attempt and gates the WRITE/END/ABORTED cases too — no destructive side effect (beginBlocklistSwap(), Update.begin()) runs before the check passes.
  • Stored XSS fix: custom blocklist domain names (attacker-controllable via /addblock — the only validation is "must contain a dot") were interpolated raw into innerHTML, and a second copy was interpolated directly into an inline onclick's JS string (a second injection vector via JS-string breakout, independent of the innerHTML one). Both now go through a small esc() HTML-escaper; the onclick was replaced with a data-d attribute plus an event-delegated click listener.
  • Default-credentials warning: if WEB_PASS/OTA_PASS are left at their secrets.example.h placeholder values, those are public (they're in this repo). The firmware now logs a warning over serial and shows a dashboard banner (new stats.json "defcreds" field) when this is the case — it still boots and runs (not blocking, to avoid bricking usability for anyone mid-setup), but it's loud about it.
  • README: new Security section documenting all of the above, plus what's explicitly out of scope — the WiFi setup portal's access point stays open (unencrypted) by design, since it needs to be joinable without a password first — and an honest note that Basic Auth over plain HTTP is a LAN-trust-boundary control, not encryption (this chip has no realistic budget to run a TLS server; credentials are base64, not encrypted, and readable by anyone who can already sniff the LAN).

/ and /stats.json stay unauthenticated by design (read-only dashboard view).

Test plan

Verified on real ESP32-C3 SuperMini hardware (flashed via pio run -t upload && pio run -t uploadfs, blocklist built via tools/build_blocklist.py):

  • Compiles clean via PlatformIO (espressif32/arduino), same flash/RAM footprint (+~2KB flash for the added checks)
  • curl http://c3adblock.local/stats.json → 200, no auth required (read-only view still open)
  • curl "http://c3adblock.local/ban?ip=1.2.3.4" (no auth) → 401
  • curl -u admin:<pass> "http://c3adblock.local/ban?ip=1.2.3.4" (correct auth) → 200
  • dig @<device-ip> doubleclick.net0.0.0.0 (blocklist still resolving/blocking correctly)
  • dig @<device-ip> github.com → real IP (forwarding still works)
  • Added a custom domain containing <img src=x onerror=alert(1)>.evil.com via /addblock → stored as-is server-side, renders as inert escaped text on the dashboard (no execution)
  • Dashboard buttons (Ban, add/remove custom domain, Forget WiFi, blocklist/firmware upload forms) all still work end-to-end through the browser with the new CSRF header attached automatically by the JS

🤖 Generated with Claude Code

Every state-changing and OTA endpoint (/ban, /addblock, /unblock,
/forgetwifi, /upload, /update, /setupdate, /fetchnow) had zero
authentication, and ArduinoOTA had no password set — anyone who could
reach the device on the LAN could reflash it with arbitrary firmware or
rewrite the blocklist with no credentials at all, on a device that sits
in the path of every DNS query on the network.

- Add HTTP Basic Auth (WEB_USER/WEB_PASS) gating every mutating
  endpoint, and ArduinoOTA.setPassword(OTA_PASS). New secrets.h fields,
  gitignored as before.
- Add a CSRF check (custom X-Requested-With header) alongside auth on
  every mutating endpoint. Basic Auth alone doesn't stop CSRF: browsers
  auto-attach cached Basic Auth credentials to any request to an
  already-authenticated origin, including one triggered by an unrelated
  page the same browser visits later (e.g. a bare <img src="http://
  c3adblock.local/forgetwifi">, no JS needed). A plain <img>/auto-submit
  <form> CSRF can't attach a custom header; only the dashboard's own
  same-origin fetch() calls can, which is what now sets it. This is why
  /forgetwifi is no longer a bare URL you can just visit — it's now a
  dashboard button.
- For the two upload endpoints (/upload, /update), auth+CSRF are
  checked inside UPLOAD_FILE_START of the multipart callback (the
  "done" handler that would normally 401 fires after the body is
  already consumed), gating a bool that's re-evaluated on every new
  upload attempt and guards the WRITE/END/ABORTED cases too, so no
  partial write happens before the check passes.
- Fix stored XSS in the dashboard: custom blocklist domain names
  (attacker-controllable via /addblock, only required to contain a dot)
  were interpolated raw into innerHTML, and a second copy was
  interpolated directly into an inline onclick's JS string (a second
  injection vector via JS-string breakout). Both now go through an
  esc() HTML-escaper; the onclick was replaced with a data-d attribute
  plus an event-delegated listener.
- Warn (serial log + dashboard banner via a new stats.json "defcreds"
  field) if WEB_PASS/OTA_PASS are left at their secrets.example.h
  placeholder values, since those are public in the repo.
- Document all of the above in a new README Security section,
  including what's explicitly out of scope (the WiFi setup portal's
  access point stays open by design, since it must be joinable without
  a password first) and an honest note that Basic Auth over plain HTTP
  is a LAN-trust-boundary control, not encryption — this chip has no
  realistic budget to run a TLS server.

Verified on real ESP32-C3 hardware: unauthenticated requests to
protected endpoints return 401/403, correct credentials + CSRF header
return 200, DNS blocking and the dashboard continue to work normally,
and the XSS payload renders as inert escaped text instead of executing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant