Skip to content

Security: timothyfroehlich/PinPoint

Security

docs/SECURITY.md

PinPoint Security Headers

Last Updated: July 12, 2026

Overview

PinPoint implements a defense-in-depth security strategy using HTTP security headers to protect against common web vulnerabilities. This document describes the security headers configuration, implementation details, and modification guidelines.

Security Headers Configuration

Static Headers (next.config.ts)

The following headers are set statically for all routes via next.config.ts:

Header Value Purpose
Strict-Transport-Security max-age=63072000; includeSubDomains; preload Enforces HTTPS connections for 2 years, including all subdomains
X-Frame-Options DENY Prevents clickjacking by blocking iframe embedding
X-Content-Type-Options nosniff Prevents MIME-sniffing attacks
Referrer-Policy strict-origin-when-cross-origin Controls referrer information sent with requests
Permissions-Policy camera=(), microphone=(), geolocation=() Disables unnecessary browser features

Dynamic Headers (middleware.ts)

Content-Security-Policy (CSP) is set dynamically in middleware to support nonce-based script execution:

Content-Security-Policy Directives

Directive Value Purpose
default-src 'self' Default policy: only load resources from same origin
script-src 'self' 'nonce-{random}' 'strict-dynamic' Scripts: same origin or with valid nonce. 'strict-dynamic' allows dynamically loaded scripts
style-src 'self' 'unsafe-inline' Styles: same origin or inline (for CSS-in-JS compatibility)
img-src 'self' data: blob: https://*.public.blob.vercel-storage.com Images: same origin, data URIs, blob URLs, or Vercel Blob CDN (uploaded avatars)
font-src 'self' data: Fonts: same origin or data URIs
connect-src 'self' {supabase-url} {supabase-ws-url} localhost Network requests: same origin, Supabase, and local development
object-src 'none' No plugins (Flash, Java, etc.)
base-uri 'self' Restricts <base> tag URLs
form-action 'self' Forms can only submit to same origin
frame-ancestors 'none' Cannot be embedded in any frame (stricter than X-Frame-Options)
block-all-mixed-content - Blocks HTTP content on HTTPS pages
upgrade-insecure-requests - Upgrades HTTP requests to HTTPS

Implementation Details

Nonce-Based CSP

PinPoint uses nonce-based Content-Security-Policy instead of 'unsafe-inline' and 'unsafe-eval' for enhanced security:

  1. Nonce Generation: A unique cryptographic nonce (UUID) is generated for each request in middleware.ts
  2. Header Injection: The nonce is injected into the CSP header as 'nonce-{uuid}'
  3. x-nonce Header: The nonce is exposed via x-nonce response header for use in Server Components
  4. Script Execution: Only scripts with matching nonce attributes can execute

Example Script Tag (future implementation):

// In a Server Component
export default async function Page() {
  const nonce = headers().get("x-nonce");

  return (
    <Script nonce={nonce}>console.log('This script will execute');</Script>
  );
}

Supabase Integration

The CSP is configured to work with Supabase authentication and real-time features:

  • HTTPS: https://{project-id}.supabase.co for API requests
  • WebSocket: wss://{project-id}.supabase.co for Realtime subscriptions
  • No Wildcards: Uses specific project URL from NEXT_PUBLIC_SUPABASE_URL instead of *.supabase.co

Local Development

CSP allows local connections for development:

  • http://127.0.0.1:* and ws://127.0.0.1:*
  • http://localhost:* and ws://localhost:*

Current Limitations

style-src 'unsafe-inline'

The CSP currently includes 'unsafe-inline' for style-src to maintain compatibility with:

  • CSS-in-JS libraries (if used in future)
  • Inline styles in third-party components
  • Server-rendered styles from Next.js

Future Improvement: Once nonce support is tested with all components, migrate to nonce-based styles:

style-src 'self' 'nonce-{random}';

script-src 'strict-dynamic'

The 'strict-dynamic' directive allows scripts loaded by nonce'd scripts to execute without their own nonce. This is necessary for:

  • Next.js dynamic imports
  • Third-party scripts loaded by trusted code
  • Client-side routing and code splitting

Trade-off: Slightly relaxes CSP but required for modern JavaScript frameworks. The initial script must still have a valid nonce.

Modifying CSP

Adding External Resources

If you need to add external resources (CDNs, analytics, etc.), update the appropriate directive in middleware.ts:

Example: Adding Google Fonts

const cspHeader = `
  ...
  font-src 'self' data: https://fonts.gstatic.com;
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
  ...
`;

Example: Adding Vercel Analytics

const cspHeader = `
  ...
  script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://va.vercel-scripts.com;
  connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://va.vercel-scripts.com;
  ...
`;

Testing CSP Changes

  1. Check Browser Console: CSP violations appear as console errors
  2. Review Network Tab: Blocked requests show "blocked:csp" status
  3. Test User Flows: Verify auth, forms, and dynamic features work
  4. Use CSP Evaluator: https://csp-evaluator.withgoogle.com/

Manual Testing on Staging/Production

Prerequisites:

  • Deployment to staging environment (e.g., Vercel preview)
  • Browser DevTools (Chrome, Firefox, or Safari)

Test Procedure:

  1. Open DevTools → Navigate to Network tab
  2. Load the application → Navigate to any page
  3. Inspect Response Headers:
    • Click on the document request (first item in Network tab)
    • Go to Headers tab
    • Scroll to Response Headers section

Verify Headers Present:

✅ strict-transport-security: max-age=63072000; includeSubDomains; preload
✅ x-frame-options: DENY
✅ x-content-type-options: nosniff
✅ referrer-policy: strict-origin-when-cross-origin
✅ permissions-policy: camera=(), microphone=(), geolocation=()
✅ content-security-policy: default-src 'self'; script-src 'self' 'nonce-...' 'strict-dynamic'; ...
✅ x-nonce: <uuid>

Verify CSP Directives:

Check that content-security-policy contains:

  • default-src 'self'
  • script-src 'self' 'nonce-<random-uuid>' 'strict-dynamic'
  • style-src 'self' 'unsafe-inline'
  • img-src 'self' data: blob: https://*.public.blob.vercel-storage.com
  • connect-src 'self' https://<project>.supabase.co wss://<project>.supabase.co
  • object-src 'none'
  • frame-ancestors 'none'

Verify Nonce:

  1. Copy nonce value from x-nonce header (e.g., a1b2c3d4-e5f6-...)

  2. Open Console tab

  3. Run test script:

    // This should FAIL (no nonce)
    const scriptFail = document.createElement("script");
    scriptFail.textContent = 'console.log("BLOCKED");';
    document.body.appendChild(scriptFail);
    
    // Check console for CSP violation error
  4. Expected Result: Console shows CSP violation error

Verify User Flows Work:

  • ✅ Authentication (login/logout)
  • ✅ Form submissions
  • ✅ Navigation between pages
  • ✅ Supabase realtime connections (if implemented)

Common Issues:

Symptom Likely Cause Fix
CSP header missing Middleware not running Check middleware matcher config
Nonce header missing Middleware error Check server logs
White screen CSP blocking scripts Check console for violations
Supabase connection fails Wrong connect-src URL Verify NEXT_PUBLIC_SUPABASE_URL

CSP Reporting (Future)

To monitor CSP violations in production, add a report-uri or report-to directive:

const cspHeader = `
  ...
  report-uri /api/csp-report;
`;

Then implement the endpoint to log violations.

Security Best Practices

  1. Never use 'unsafe-eval': Allows arbitrary code execution
  2. Minimize 'unsafe-inline': Use nonces or hashes instead
  3. Avoid wildcards: Be specific with allowed origins
  4. Test thoroughly: CSP can break legitimate functionality
  5. Monitor violations: Set up CSP reporting in production
  6. Keep updated: Review and tighten CSP as codebase evolves

Authentication CAPTCHA (Turnstile fail-open)

PinPoint's four auth actions (login, signup, forgot-password, reset-password) verify a Cloudflare Turnstile token fail-open in verifyTurnstileFailOpen (src/lib/security/turnstile.ts): the gate always allows the submission through, even when the token is missing, rejected, or unverifiable. This is an intentional, Tim-signed-off tradeoff (PP-20yy): availability > strict captcha enforcement, because a fail-closed gate repeatedly locked real users out when the Turnstile widget failed transiently. IP + account rate limiting is the real abuse backstop; captcha here is best-effort deterrence.

Invariant: Supabase project-level captcha MUST stay disabled

Because PinPoint verifies Turnstile itself (fail-open) instead of delegating to Supabase's built-in captcha protection, Supabase Auth captcha protection must remain disabled for the project. If it were enabled, Supabase would reject the tokenless submissions the fail-open gate is designed to let through — upstream of our check — silently breaking auth for every user whose widget fails, and it would double-spend the single-use token we already consume.

This precondition is enforced only by convention today (a code comment in verifyTurnstileFailOpen and a NOTE at each call site in src/app/(auth)/actions.ts); there is no runtime assertion. A startup assertion was deliberately deferred (PP-vo43) because reading the setting requires a Supabase Management API call on the boot path, which could break startup. The durable enforcement is the documented invariant plus a manual dashboard check — see the monitoring runbook: docs/runbooks/turnstile-fail-open-monitoring.md.

Monitoring: fail-open spike

Every fail-open path emits a structured log line (event: "turnstile_fail_open") and a Sentry message (turnstile.fail_open, tagged with action and turnstileOutcome). A sustained spike may indicate bot abuse exploiting the weakened gate (or a Turnstile outage). The alert threshold (> 50/hour → security channel) and the spike action plan (triage by outcome → tighten rate limits → last-resort re-enable Supabase captcha + fail-closed gate in tandem) are documented in docs/runbooks/turnstile-fail-open-monitoring.md. Creating the Sentry alert rule and verifying the live Supabase setting are manual dashboard actions tracked there as follow-ups.

Threat Model

Protected Against

  • XSS (Cross-Site Scripting): Nonce-based CSP prevents unauthorized script execution
  • Clickjacking: X-Frame-Options and frame-ancestors prevent iframe embedding
  • MIME Sniffing: X-Content-Type-Options prevents content type confusion
  • Protocol Downgrade: HSTS enforces HTTPS
  • Mixed Content: CSP blocks HTTP resources on HTTPS pages

Not Protected Against

  • CSRF: Requires additional token-based protection (not yet implemented)
  • SQL Injection: Prevented by Drizzle ORM parameterization (separate concern)
  • Abuse on non-auth surfaces: Rate limiting is not comprehensive across the app. Auth actions are protected — IP + account rate limiting via ~/lib/rate-limit, plus the best-effort fail-open Turnstile gate (see "Authentication CAPTCHA (Turnstile fail-open)" above) — but other surfaces are not comprehensively rate-limited.
  • DDoS: Requires infrastructure-level protection

References

Related Documentation

  • middleware.ts - CSP implementation
  • next.config.ts - Static security headers
  • docs/NON_NEGOTIABLES.md - Security requirements
  • docs/TECH_SPEC.md - Architecture security considerations

There aren't any published security advisories