Author • Eno

Security Audit Report — z0rs.github.io

  • Security
  • SSRF
  • CSRF
  • HTTP Headers
  • Gatsby

Executive Summary

During a comprehensive code audit of this blog's codebase, 7 distinct security findings were identified across multiple files. Findings range from critical SSRF to informational hygiene issues. The primary SSRF vulnerability has been fixed; the remaining findings represent acceptable risk or documentation concerns.

#FindingFileSeverityStatus
1SSRF via MDX frontmatter URLsgatsby-node.jsCriticalFixed
2.env.example documents real secret names.env.exampleLowUnfixed
3GA Measurement ID embedded client-sideroot-element.jsLowBy design
4Hardcoded site URL in WebmentionAsidetemplates/article.js, ctf.jsLowUnfixed
5dangerouslySetInnerHTML in SSRgatsby-ssr.jsLowAcceptable
6Missing HTTP security headersgatsby-ssr.jsMediumUnfixed
7CSRF / rate limitingN/AN/ANot applicable

Finding 1 — SSRF via MDX Frontmatter URLs

Severity: Critical
Status: Fixed
File: gatsby-node.js lines 6–18, 88–186

Description

Three frontmatter fields — featuredImage, embeddedImages, and logo — accept arbitrary URLs that are passed directly to createRemoteFileNode() during Gatsby's build phase. Without validation, an attacker who can push MDX content (or modify existing files) can trigger HTTP requests to internal infrastructure from the build server's network.

Attack Scenarios

Internal network scanning:

featuredImage: "http://169.254.169.254/latest/meta-data/" # AWS metadata
featuredImage: "http://192.168.1.1/admin" # Router admin
featuredImage: "http://10.0.0.1:5432" # Database port scan

Build servers often run on cloud infrastructure (AWS EC2, GCP, Azure) with access to internal VPCs and metadata endpoints. The 169.254.169.254 endpoint provides instance credentials and IAM roles if the build server has a service account.

Local service access:

featuredImage: "http://localhost:8080/debug"
featuredImage: "http://127.0.0.1:9000/health"

Fix Applied

function isAllowedUrl(url) {
try {
const { hostname, protocol } = new URL(url);
if (!['http:', 'https:'].includes(protocol)) return false;
const blocked = ['localhost', '127.0.0.1', '0.0.0.0'];
if (blocked.includes(hostname)) return false;
const isPrivate =
/^10\.|^172\.(1[6-9]|2\d|3[01])\.|^192\.168\.|^127\./.test(hostname) || hostname === '169.254.169.254'; // AWS metadata
return !isPrivate;
} catch {
return false;
}
}

Each createRemoteFileNode() call is now gated with isAllowedUrl(). Blocked URLs are logged with [gatsby-node] Blocked SSRF attempt and skipped without fetching.


Finding 2 — .env.example Documents Real Secret Names

Severity: Low
Status: Unfixed
File: .env.example

Description

.env.example contains placeholder entries that reveal the names of real credentials the project once used or planned to use:

FAUNA_KEY=""
GOOGLE_PROJECT_ID=""
GOOGLE_CLIENT_EMAIL=""
GOOGLE_PRIVATE_KEY=""
GOOGLE_GA4_PROPERTY_ID=""
GATSBY_API_URL=""
CK_FORM_ID=""
CK_API_KEY=""

The actual .env file was never committed. However, the example file itself is a reconnaissance aid — FAUNA_KEY, CK_API_KEY, and GOOGLE_PRIVATE_KEY specifically telegraph what credentials an attacker should look for if they compromise the environment.

Proof of Concept

  1. Clone the repository
  2. Read .env.example
  3. Infer that the project uses FaunaDB, Google Cloud service accounts, and Classic Forms
  4. Attempt to locate these credentials via supply chain attacks, leaked CI logs, or public code commits

Remediation

Remove credential-type variable names from .env.example. Use generic names:

# Before (reveals credential types)
FAUNA_KEY=""

# After (generic)
DB_SECRET=""

Or remove .env.example entirely if the project no longer uses any of these services.


Finding 3 — Google Analytics Measurement ID Client-Side

Severity: Low
Status: By design
File: src/components/root-element.js, gatsby-config.mjs

Description

process.env.GATSBY_GA_MEASUREMENT_ID is baked into the client bundle at build time via two <Script> tags in root-element.js:

<Script src={`https://www.googletagmanager.com/gtag/js?id=${process.env.GATSBY_GA_MEASUREMENT_ID}`} ... />
gtag('config', process.env.GATSBY_GA_MEASUREMENT_ID, { send_page_view: false })

This is expected behavior in Gatsby — all GATSBY_* env vars are public by design. The GA Measurement ID is a public token intended for client-side inclusion.

The only risk is if the token is exposed in server-side logs or error messages. The partytownProxiedURLs in gatsby-config.mjs references the same variable — if unset, Partytown proxies a malformed URL https://www.googletagmanager.com/gtag/js?id= which is cosmetic, not exploitable.


Finding 4 — Hardcoded Site URL in WebmentionAside

Severity: Low
Status: Unfixed
Files: src/templates/article.js, src/templates/ctf.js

Description

Both templates hardcode the site URL for WebmentionAside:

<WebmentionAside target={`https://z0rs.github.io${slug}`} />

siteMetadata.siteUrl is available in the template context but is not used. If the site is ever deployed to a different domain (e.g., a custom domain), Webmentions would point at the wrong domain silently.

Proof of Concept

  1. Change siteUrl in gatsby-config.mjs to https://example.com
  2. Build — WebmentionAside still sends webmentions to z0rs.github.io
  3. No webmentions received at the new domain

Fix

<WebmentionAside target={`${siteUrl}${slug}`} />

Finding 5 — dangerouslySetInnerHTML in SSR

Severity: Low
Status: Acceptable
File: gatsby-ssr.js lines 59–86

Description

onRenderBody uses dangerouslySetInnerHTML to inject @font-face declarations:

dangerouslySetInnerHTML={{
__html: `
@font-face {
font-family: 'Inconsolata';
font-weight: 400;
src: url(/fonts/Inconsolata-Regular.woff2) format('woff2');
}
...
`
}}

The content is entirely static — hardcoded font declarations with only local /fonts/ paths. No user-controlled input, no variable interpolation, no MDX rendering. This is React's recommended pattern for injecting global CSS.

Not exploitable.


Finding 6 — Missing HTTP Security Headers

Severity: Medium
Status: Unfixed
File: gatsby-ssr.js

Description

onRenderBody sets favicon links and font preloads but does not set HTTP security headers. The following are absent:

HeaderPurpose
Content-Security-PolicyPrevents XSS by restricting resource loading
X-Frame-OptionsClickjacking protection
X-Content-Type-Options: nosniffPrevents MIME sniffing
Referrer-PolicyControls referrer header leakage
Permissions-PolicyDisables browser features not used

Gatsby's built-in server (gatsby serve) does not set security headers by default. If the site is served directly without a CDN, all headers are absent.

Fix

Add to onRenderBody in gatsby-ssr.js:

export const onRenderBody = ({ setHeadComponents }) => {
setHeadComponents([
// existing components...
<meta key="x-frame-options" httpEquiv="X-Frame-Options" content="DENY" />,
<meta key="x-content-type-options" httpEquiv="X-Content-Type-Options" content="nosniff" />,
<meta key="referrer-policy" httpEquiv="Referrer-Policy" content="strict-origin-when-cross-origin" />
]);
};

Content-Security-Policy requires careful crafting based on the site's actual resource loading patterns and is beyond a simple audit fix.


Finding 7 — CSRF / Rate Limiting

Severity: N/A
Status: Not applicable

This is a static site generated entirely at build time. There are no src/api/ routes, no server-side API endpoints, and no user-submitted form data processed by the application.

CSRF and rate limiting are relevant for dynamic server-side applications that process state-changing requests. Static generation produces no runtime request handling — every page is a pre-rendered HTML file served from CDN or GitHub Pages.

The reaction system (which would have required CSRF and rate limiting) was removed because GitHub Pages cannot host server-side functions.


Disclosure Timeline

  • 2026-04-29: Audit started — all files reviewed including gatsby-node.js, components, templates, configs, MDX content, and package.json
  • 2026-04-29: SSRF vulnerability identified and fixed in gatsby-node.js
  • 2026-04-29: Report published with all 7 findings documented

Built with Gatsby ^5.16.1