Skip to content

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 scriptapps/api/public/embed.js and the rich-text allowlist walker.
  • Marketing siteapps/marketing (Astro static build).
  • Infrastructure — nginx (TLS, headers, CSP), Docker Compose layout, cron-driven backup, fail2ban, health-check loop.
  • Open-source supply chaincomposer 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

TierDefinition
CRITICALDirect, unauthenticated impact: data exposure, RCE, account takeover. Patched within hours of discovery; we never ship with one open.
HIGHAuthenticated but trivially-chained impact, or unauthenticated with a meaningful precondition. Patched in the same audit cycle.
MEDIUMExploitable only with chained preconditions or unrealistic attacker capabilities — still always remediated before launch.
LOWDefence-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:

  1. nginx strips the CF-IPCountry header on every request entering the /v1/ location (proxy_set_header CF-IPCountry "";).
  2. We document that production deploys MUST sit behind an edge that re-injects the header, and we ship the nginx config that does so.
  3. 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:

  1. DocumentValidator::addRule(new DisableIntrospection(DisableIntrospection::ENABLED)) — introspection queries now return a rule-violation error.
  2. The catch block returns the fixed string execution failed and silently report($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-guardian agent — static + dynamic review, drives the three-phase audit (security, hygiene, functional).
  • security-guidance plugin — 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 explorationcurl-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-guardian Anthropic-Claude agent against the production deployment at peacock-cms.webhoch.com and the Closed Pre-Alpha tree on webhoch-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 audit in 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.

Zuletzt aktualisiert: