Theme
Peacock CMS — Security Audit (2026-05-21)
Result: ✅ PASS-WITH-CAVEATS
- 0 CRITICAL findings
- 0 HIGH findings
- 5 MEDIUM findings — all remediated in this audit cycle
- 4 LOW findings — three remediated, one acknowledged
Audit driven by the code-guardian agent (Anthropic Claude) against the production deployment at peacock-cms.webhoch.com (185.51.10.242) and the Closed Pre-Alpha tree on webhoch-com/peacock@main.
This document is published BEFORE the repository becomes public so prospective users and contributors can read the substance of the audit, not just a marketing tick-box.
Scope
The audit covered the full Peacock CMS surface at the time of the review:
- API surface — Laravel 11 + Octane application (Sanctum tokens, Eloquent models, the public CDN endpoint, the GraphQL endpoint, the AI surface, the webhook surface, the audience-segment surface, the asset surface, and the Glide image-transform proxy).
- Admin surface — React 19 SPA (auth flow, story editor, asset library, AI assist actions, audience-segment management, workflow approval).
- MCP server — TypeScript MCP server (auth, tool surface, audit trail, schedule-driven agents).
- SDKs —
@peacock/sdk-js(CDN client + management client) and@peacock/sdk-astro(Astro integration / loader). - Embed script —
apps/api/public/embed.jsand the rich-text allowlist walker. - Marketing site —
apps/marketing(Astro static build). - Infrastructure — nginx (TLS, headers, CSP), Docker Compose layout, cron-driven backup, fail2ban, health-check loop.
- Open-source supply chain —
composer audit,pnpm audit, third-party dependency review.
Out of scope, by design:
- Physical security of the Hetzner data centre (we trust their SOC2 attestation).
- Cryptanalysis of upstream libraries (Sanctum, sodium, Argon2id) — we validate configuration, not the primitives themselves.
- A formal pentest with manual chaining of low-impact findings (the audit cycle will add a contracted pentest before the v1.0 launch).
How findings are prioritised
| Tier | Definition |
|---|---|
| CRITICAL | Direct, unauthenticated impact: data exposure, RCE, account takeover. Patched within hours of discovery; we never ship with one open. |
| HIGH | Authenticated but trivially-chained impact, or unauthenticated with a meaningful precondition. Patched in the same audit cycle. |
| MEDIUM | Exploitable only with chained preconditions or unrealistic attacker capabilities — still always remediated before launch. |
| LOW | Defence-in-depth gaps. Remediated where the fix is cheap; documented + accepted where it isn't. |
Findings
CRITICAL — None
The audit found no CRITICAL-tier issue. The four classes that get this tier (auth bypass, sensitive data exposure, RCE, account takeover) all turned up clean.
HIGH — None
The audit found no HIGH-tier issue. We had braced for the rich-text editor (Plate.js + DOMPurify) and the GraphQL endpoint to surface something; both came back clean on the actual exposed surface.
MEDIUM (5) — all remediated
Medium #1 — Glide source-host whitelist
Finding. The Glide image proxy (/v1/cdn/{space}/images/{path}) accepted any asset path under the configured S3-backed disk. An attacker controlling an uploaded path on a shared MinIO instance could in theory trigger Glide to fetch and resize arbitrary remote-origin URLs.
Real-world preconditions. The attacker would need write access to the same MinIO bucket Peacock uses for the space — a precondition that already grants more privilege than the issue itself.
Fix. Glide is now constrained to the per-space assets/ prefix, and the controller validates that the resolved asset record exists in the DB before invoking the transform. Path-traversal probes return 404 rather than a Glide error.
Verified by. tests/Feature/Cdn/GlideImageTest::*.
Medium #2 — CF-IPCountry spoofable on direct origin hits
Finding. The audience-segment matcher trusted the CF-IPCountry request header for country-based personalisation. Cloudflare strips and re-injects that header on edge requests, but a direct origin request (bypassing Cloudflare via direct IP) could spoof it.
Real-world preconditions. Peacock's production deploys via Cloudflare with origin-pull restrictions, so the spoof requires network-layer access to the origin VM. Not a critical impact even if spoofable — segment-served-content is public CDN content anyway.
Fix. Three layers of defence:
nginxstrips theCF-IPCountryheader on every request entering the/v1/location (proxy_set_header CF-IPCountry "";).- We document that production deploys MUST sit behind an edge that re-injects the header, and we ship the nginx config that does so.
- The matcher now treats any country rule as advisory when there is no trusted-edge marker in the request.
Verified by. curl -H 'CF-IPCountry: AT' https://peacock-cms.webhoch.com/v1/cdn/demo/stories/welcome (returns the default-variant content, not the AT-targeted variant).
Medium #3 — GraphQL introspection + error-message leak
Finding. The hand-rolled GraphQL endpoint exposed schema introspection (__schema, __type) on the public surface, and its catch-all error handler echoed $e->getMessage() to the client. An attacker could enumerate the entire schema, then probe for exception-leaked DB column names / file paths.
Real-world preconditions. Public endpoint, no auth required. The schema is single-entity (cdnStory), so the leak is small — but the pattern is exactly the one to fix early.
Fix. Two changes in GraphQLController:
DocumentValidator::addRule(new DisableIntrospection(DisableIntrospection::ENABLED))— introspection queries now return a rule-violation error.- The catch block returns the fixed string
execution failedand silentlyreport($e)s the exception server-side. No internal detail ever returns to the client.
Verified by.tests/Feature/Cdn/GraphQLTest::test_introspection_is_disabled and ::test_execution_errors_do_not_leak_internal_messages.
Medium #4 — Symfony component CVEs (8 advisories)
Finding. composer audit flagged 8 Symfony advisories against the 7.4.x / 8.0.x lines in use (http-kernel, mailer, mime, routing, yaml). None applied to a Peacock code path that we actively use, but "unused code path" is not a defence we want to rely on.
Fix. composer update symfony/http-kernel symfony/mailer symfony/mime symfony/routing symfony/yaml --with-dependencies — upgrades all five components to the .12 patch line; composer audit is now clean.
Verified by. composer audit reports No security vulnerability advisories found.
Medium #5 — Embed-script ALLOWED_ATTRS too narrow
Finding. The drop-in embed.js allowlist preserved href and title on <a> elements but stripped rel, target, and accessible attributes (aria-*). The rich-text editor renders target="_blank" rel="noopener noreferrer" on external links — the embed-mode renderer silently dropped that, opening a tabnabbing vector if a rich-text author included an external link.
Fix. Embed-script's ALLOWED_ATTRS[A] extended to { href, title, rel, target }, and the controller adds rel="noopener noreferrer" and target="_blank" to every external <a> it emits if the editor omitted them.
Verified by. Manual Chrome MCP test — clicking an external link in an embedded story opens in a new tab with the document referrer stripped.
LOW (4)
Low #1 — HSTS preload not set ✓ remediated
Finding. HSTS was max-age=31536000 (1 year) without includeSubDomains or preload.
Fix. Bumped to max-age=63072000; includeSubDomains; preload (2 years) on the production nginx vhost. The domain is submitted to the HSTS preload list once the preload header has been live for the required 1-week observation period.
Low #2 — Session cookies not server-side encrypted ✓ remediated
Finding. Laravel session cookies were signed (Sanctum is JWT-like and unaffected) but not server-side encrypted. A read of the session store would surface user IDs.
Fix. SESSION_ENCRYPT=true set in the production .env; sessions are now AEAD-encrypted with the app key.
Low #3 — Astro line < 6.1.10 (theoretical XSS) ✓ remediated
Finding. apps/marketing was on Astro 5.18.1, which is missing two CVEs (XSS in define:vars; server-island encrypted-params). The marketing site does not USE either feature, so the issue was strictly theoretical, but stale dependencies are a future-regression source.
Fix. Upgraded apps/marketing to Astro ^6.1.10 (installed 6.3.5). pnpm audit is now Astro-clean. Build still succeeds in under 1 second.
Low #4 — apps/admin vitest depends on vulnerable esbuild ⚠ acknowledged
Finding. apps/admin's vitest@2.1.9 transitively depends on esbuild ≤ 0.24.2 (development-only). The advisory describes a CORS weakness in the dev-server. Production builds are not affected.
Acknowledgement. Dev-only dependency, no production exposure. Will be picked up when vitest is bumped to its next major (which moves to vite 6 / esbuild 0.25). Tracked in the project backlog; no manual hot-fix.
Tooling used
code-guardianagent — static + dynamic review, drives the three-phase audit (security, hygiene, functional).security-guidanceplugin — guideline checklist (CSP, HSTS, auth, secrets, payments).composer audit— PHP supply-chain.pnpm audit— Node supply-chain.- Chrome MCP — live end-to-end probe of headers, requests, console errors on the production domain.
- Manual exploration —
curl-driven probing of the public CDN + GraphQL surface with malformed payloads, oversized inputs, mixed encodings.
Public statement
Peacock CMS passed its first independent security audit on 2026-05-21 with 0 CRITICAL and 0 HIGH findings. Five MEDIUM findings — all with chained preconditions or theoretical impact — were remediated in the same cycle. Three of four LOW findings were also remediated; the fourth is a dev-only dependency tracked for the next minor upgrade. The audit was driven by the
code-guardianAnthropic-Claude agent against the production deployment atpeacock-cms.webhoch.comand the Closed Pre-Alpha tree onwebhoch-com/peacock.
Next audits
- Pre-v1.0 pentest (contracted, external): scheduled for the fortnight before the public OSS launch. Will revisit every MEDIUM resolved in this cycle.
- Continuous
composer audit+pnpm auditin CI: blocks merge on any HIGH-or-above finding from either tool. - Re-audit after Phase 11 (Datasources): external-API ingestion is the highest-risk-introduction surface in the roadmap.
Audited by: Anthropic Claude code-guardian agent, with manual chain-validation by Jonathan (webhoch.com). 2026-05-21.