SecOpsAI Public Sites Security Audit
Generated: 2026-05-12
Scope:
- Main website:
https://secopsai.dev/andhttps://www.secopsai.dev/ - Docs site:
https://docs.secopsai.dev/ - Blog site:
https://blog.secopsai.dev/ - Local code:
www/,website/,docs/,blog/,secopsai/blog.py,scripts/verify_blog.py
Branding note:
- The canonical public icon was restored from the preferred dark-green
www.secopsai.devheader logo (www/assets/header-logo.jpg). A cropped square PNG derivative is now used for website, docs, and blog favicon/apple-touch assets, and the blog header brand mark uses that icon.
1. Vulnerability Summary
- Critical: 0
- High: 0
- Medium: 3
- Low: 4
2. Detailed Findings
Blog security headers lagged behind website/docs
- Severity: Medium
- Affected component:
blog/_headers - Description: The blog had basic
nosniff, referrer policy, permissions policy, and CSP, but lacked the stronger header set already used by website/docs: HSTS,X-Frame-Options,base-uri,object-src,form-action, and cross-origin isolation hints. - Exploitation scenario: An attacker attempts clickjacking, plugin/object injection, insecure downgrade paths, or base tag manipulation against the blog. CSP and frame protections reduce those paths, but the blog policy was weaker than the rest of the public surface.
- Impact: Defense-in-depth gap and inconsistent hardening across public properties.
- Recommended fix: Align blog headers with website/docs and keep CSP restrictive.
- Fix status: Fixed now.
blog/_headersnow includes HSTS,X-Frame-Options,base-uri,object-src,form-action,Cross-Origin-Opener-Policy, andCross-Origin-Resource-Policy.
Blog comments accepted JSON without request-shape guardrails
- Severity: Medium
- Affected component:
blog/_worker.js,blog/functions/api/comments.js - Description: The comments API validated fields after parsing JSON, but did not reject large request bodies or non-JSON content types before parsing.
- Exploitation scenario: An anonymous user sends oversized request bodies or non-JSON payloads repeatedly to increase worker/Supabase pressure.
- Impact: Low-cost resource abuse against the comments endpoint.
- Recommended fix: Enforce a small content length limit, require
application/json, keep strict field length checks, and add rate limiting/Turnstile for public scale. - Fix status: Fixed now. Content length and content type checks were added, and optional Cloudflare Turnstile verification is enforced whenever
TURNSTILE_SECRET_KEYis configured.
Main website depends on runtime Tailwind CDN and inline scripts/styles
- Severity: Medium
- Affected component:
www/index.html,www/_headers - Description: The main website allows
unsafe-inlineand loads Tailwind fromhttps://cdn.tailwindcss.com. This is acceptable for a small static marketing page, but it weakens CSP and adds a third-party script trust boundary. - Exploitation scenario: If the CDN script is compromised, blocked, or maliciously modified upstream, the site trusts it at runtime. Inline script allowance also makes future XSS mistakes more dangerous.
- Impact: Broader script execution surface on the main marketing site.
- Recommended fix: Build Tailwind at deploy time, serve static CSS from
self, move inline JavaScript to a static file, and replaceunsafe-inlinewith nonces or hashes. - Fix status: Fixed now for runtime script risk. Tailwind is compiled into
www/assets/site-tailwind.css, the runtime Tailwind CDN script was removed, page JavaScript was moved towww/assets/site.js, andwww/_headersno longer allows CDN script/connect sources orunsafe-inlinescripts. A small residualstyle-src 'unsafe-inline'allowance remains because the page still carries local inline custom CSS; removing that is a lower-risk follow-up because no inline script execution is allowed.
Blog comments endpoint returned 405 to HEAD health checks
- Severity: Low
- Affected component:
blog/_worker.js - Description:
GET /api/comments?health=1returned JSON, butHEADrequests got405. Some uptime and security probes useHEAD. - Exploitation scenario: Monitoring falsely reports the endpoint as down even though GET health works.
- Impact: Operational confusion, not a direct exploit.
- Recommended fix: Return
200forHEAD /api/comments. - Fix status: Fixed now.
RSS and JSON feed links were raw-machine-readable in browsers
- Severity: Low
- Affected component:
secopsai/blog.py,blog/feed.xml,blog/feed.json,blog/rss.xsl,blog/json-feed.html - Description: Clicking RSS or JSON Feed in a browser displayed raw XML/JSON. RSS also had an empty item description because the post summary was empty.
- Exploitation scenario: Not a direct security flaw, but confusing feed UX can cause users to distrust legitimate feed links or miss incident context.
- Impact: Poor operator usability and incomplete feed metadata.
- Recommended fix: Add an RSS stylesheet, provide a human-readable JSON feed landing page, and ensure feed summaries are populated.
- Fix status: Fixed now.
Static public assets expose Access-Control-Allow-Origin: *
- Severity: Low
- Affected component: Cloudflare Pages responses for static pages/assets
- Description: Live responses include
access-control-allow-origin: *for public static resources. No sensitive authenticated data is exposed by these static pages, so the risk is low. - Exploitation scenario: Other sites can read public static HTML/feed responses cross-origin. This would become risky if authenticated or private data were served from the same paths.
- Impact: Low for current public-only content.
- Recommended fix: Keep private/API data behind explicit worker routes with restrictive responses. Avoid adding sensitive endpoints to static paths.
- Fix status: Documented.
Blog comment spam/rate limiting still relies on moderation and honeypot
- Severity: Low
- Affected component:
blog/assets/comments.js,blog/_worker.js, Supabase table moderation workflow - Description: Comments are pending by default and include a honeypot field, field limits, and IP hashing, but there is no Turnstile or edge rate limit yet.
- Exploitation scenario: A bot submits many pending comments. They are not public, but they can create moderation load and Supabase write volume.
- Impact: Moderation/operational load.
- Recommended fix: Add Cloudflare Turnstile or a Workers KV/Durable Object/IP-rate limit if public comment volume increases.
- Fix status: Fixed now with staged Turnstile. The comments client fetches public Turnstile config, renders the widget when configured, and both Pages handlers verify the token against Cloudflare before writing pending comments when
TURNSTILE_SECRET_KEYexists. Existing mitigations remain pending-only comments, honeypot, strict validation, and body-size limits.
3. Attack Chains
Comment-spam resource pressure
- Anonymous attacker discovers
/api/comments. - They submit many syntactically valid comments.
- Comments stay
pending, so there is no public XSS or content injection. - Without edge rate limiting, the attacker can still create moderation and Supabase write pressure.
Mitigations now in place: content-type checks, payload-size cap, field limits, honeypot, safe text rendering, pending-only writes, approved-only reads, and optional Cloudflare Turnstile enforcement.
Third-party script trust on main website
- Anonymous attacker cannot directly write to the website.
- The website trusts Tailwind CDN script at runtime and allows inline scripts.
- If an upstream CDN or supply-chain path is compromised, injected JavaScript runs in the page context.
- Because the site is static and does not store credentials, impact is mainly visitor-side phishing/content manipulation.
Mitigations now in place: CSS is built locally, JavaScript is local, runtime CDN trust was removed, and script-src is restricted to self.
4. Secure Design Recommendations
- Keep the blog comments API server-side only; never expose
SUPABASE_SERVICE_ROLE_KEYto client code. - Keep Turnstile configured for public comments, or add a Cloudflare edge rate limit if comment traffic grows.
- Continue moving remaining main-site inline custom CSS into static files when convenient so
style-src 'unsafe-inline'can also be removed. - Continue using
textContentfor comments and generated HTML escaping for posts/feeds. - Keep RSS/JSON feed generation deterministic and source-backed.
- Keep public site headers aligned across
www,docs, andblog. - Add a future CI verifier that checks deployed
/api/comments?health=1returns JSON and that all public sites use the canonical favicon hash.
5. Verification
Commands run:
shasum -a 256 docs/assets/favicon.svg www/assets/favicon.svg blog/favicon.svg blog/assets/favicon.svg website/favicon.svg
shasum -a 256 docs/assets/favicon-512.png www/assets/favicon-512.png blog/assets/favicon-512.png website/assets/favicon-512.png
python3 -m secopsai.cli blog rebuild-feeds
python3 scripts/verify_docs_examples.py
node --check blog/_worker.js
node --check blog/functions/api/comments.js
node --check blog/assets/blog.js
node --check blog/assets/comments.js
node --check www/assets/site.js
python3 scripts/verify_blog.py
git diff --check
curl -sS 'https://blog.secopsai.dev/api/comments?health=1'
curl -sS -I https://secopsai.dev/
curl -sS -I https://www.secopsai.dev/
curl -sS -I https://docs.secopsai.dev/
curl -sS -I https://blog.secopsai.dev/
curl -sS -I 'https://blog.secopsai.dev/api/comments?health=1'
curl -sS -I https://blog.secopsai.dev/feed.xml
curl -sS -I https://blog.secopsai.dev/feed.json
rg -n "localStorage|sessionStorage|innerHTML|insertAdjacentHTML|eval\\(|new Function|document\\.write|SUPABASE|service_role|api[_-]?key|token=|secret=|password|fetch\\(|Content-Security|Access-Control" -S www website blog docs secopsai scripts mkdocs.yml .github
Local checks passed:
- Blog verifier passed.
- Blog worker syntax passed.
- Blog Pages Function syntax passed.
- Canonical cropped
www/assets/header-logo.jpgfavicon hashes match copied website/docs/blog assets.
Live checks after redeploy:
- Website redeployed to
https://d7eab1e0.website-bks.pages.dev. - Docs redeployed to
https://55b40532.secopsai.pages.dev. - Blog redeployed to
https://fcda3d4f.secopsai-blog.pages.dev. https://blog.secopsai.dev/api/comments?health=1returned JSON withconfigured: true,turnstile_required: false, and optional Turnstile keys missing until configured.HEAD /api/comments?health=1returned200.https://blog.secopsai.dev/feed.xmlincludes an RSS stylesheet and populated item description.- Browser-style requests to
https://blog.secopsai.dev/feed.jsonreturn the human-readable JSON feed landing page. - Feed/API clients requesting JSON still receive the raw JSON feed.
https://secopsai.dev/includes the Blog navigation link, localsite-tailwind.css, localsite.js, the visibleheader-logo.jpgbrand image, and the canonical cropped dark-green header-logo favicon reference.https://docs.secopsai.dev/includes the canonical cropped dark-green header-logo favicon and header mark.https://blog.secopsai.dev/includes the canonical cropped dark-green header-logo favicon, brand mark, local blog script, and JSON Feed links.
Remaining manual/future steps:
- Configure
TURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEYin thesecopsai-blogPages project to activate the widget and server-side challenge enforcement. - Consider moving the remaining inline custom CSS in
www/index.htmlinto a static CSS file sostyle-src 'unsafe-inline'can be removed too.