Files
will.anderson 1eeb8df04b Update CORS test: no-Origin requests are allowed (same-origin fix)
Same-origin browser fetches don't send Origin. The server correctly
allows them — blocking was the bug that broke checkout. Update the
test to match the fixed behavior.
2026-05-11 15:22:22 -05:00

215 lines
8.9 KiB
TypeScript

import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app';
async function get(path: string, headers: Record<string, string> = {}) {
return fetch(`${BASE}${path}`, { headers, redirect: 'manual' });
}
async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
return fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}
// ── Security headers ──────────────────────────────────────────────────────────
// All HTML pages and API responses carry the full security header suite.
// The El runtime's handle_request wrapper applies sec_headers_json() to every
// response, so we can assert the same set on both HTML pages and JSON APIs.
test.describe('Security headers', () => {
const htmlPages = ['/', '/about', '/checkout?plan=professional'];
for (const path of htmlPages) {
test(`HTML ${path} — required security headers present`, async () => {
const r = await get(path);
expect(r.headers.get('x-content-type-options')).toBe('nosniff');
expect(r.headers.get('x-frame-options')).toMatch(/DENY|SAMEORIGIN/i);
expect(r.headers.get('referrer-policy')).toBeTruthy();
expect(r.headers.get('content-security-policy')).toBeTruthy();
});
}
test('API responses carry x-content-type-options', async () => {
const r = await get('/api/health');
expect(r.headers.get('x-content-type-options')).toBe('nosniff');
});
test('permissions-policy header is present', async () => {
const r = await get('/');
expect(r.headers.get('permissions-policy')).toBeTruthy();
});
});
// ── CORS enforcement on /api/supabase-config ──────────────────────────────────
// This endpoint enforces an explicit origin allowlist:
// - empty Origin (server-side / curl): BLOCKED (403)
// - https://neurontechnologies.ai: ALLOWED
// - https://www.neurontechnologies.ai: ALLOWED
// - http://localhost:*: ALLOWED (dev)
// - anything else (e.g. evil.com): BLOCKED (403)
test.describe('CORS enforcement — /api/supabase-config', () => {
test('Allows requests with no Origin header (same-origin browser fetches)', async () => {
// Same-origin browser fetches (e.g. checkout page fetching supabase-config on
// the same domain) do not send an Origin header. The server must pass these
// through — blocking them would break the checkout flow on production.
// Server-side exfiltration is prevented by the evil-origin 403 below.
const r = await get('/api/supabase-config');
expect(r.status).toBe(200);
});
test('Rejects evil origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://evil.com' });
expect(r.status).toBe(403);
});
test('Allows neurontechnologies.ai origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' });
expect(r.status).toBe(200);
const body = await r.json() as Record<string, string>;
expect(body.url).toMatch(/supabase\.co/);
expect(typeof body.anon_key).toBe('string');
expect(body.anon_key.length).toBeGreaterThan(20);
});
test('Allows www.neurontechnologies.ai origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://www.neurontechnologies.ai' });
expect(r.status).toBe(200);
});
test('Allows localhost origin (dev)', async () => {
const r = await get('/api/supabase-config', { Origin: 'http://localhost:3001' });
expect(r.status).toBe(200);
});
});
// ── Auth enforcement on /api/demo ─────────────────────────────────────────────
// All requests require a valid Supabase access_token.
// Missing or invalid tokens return {"auth_required":true}.
test.describe('Auth enforcement — /api/demo', () => {
test('Rejects POST with no access_token', async () => {
const r = await post('/api/demo', { message: 'hello' });
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required).toBe(true);
});
test('Rejects POST with invalid access_token', async () => {
const r = await post('/api/demo', { message: 'hello', access_token: 'invalid.token.here' });
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required).toBe(true);
});
test('Rejects empty message (length guard fires after auth check)', async () => {
// With no token, auth check fires first
const r = await post('/api/demo', { message: '', access_token: 'invalid' });
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required || body.error).toBeTruthy();
});
});
// ── Stripe webhook signature enforcement ──────────────────────────────────────
test.describe('Stripe webhook security', () => {
test('Rejects POST with no Stripe-Signature header', async () => {
const r = await post('/api/webhooks/stripe', {
type: 'payment_intent.succeeded',
data: { object: { amount: 9900 } },
});
expect(r.status).toBe(400);
});
test('Rejects POST with malformed Stripe-Signature', async () => {
const r = await post(
'/api/webhooks/stripe',
{ type: 'payment_intent.succeeded', data: { object: {} } },
{ 'Stripe-Signature': 't=1234,v1=fakesignature' },
);
expect(r.status).toBe(400);
});
});
// ── Information leakage — source and build files must not be exposed ──────────
// The Docker image copies only compiled artifacts and static assets into
// /srv/landing/. Source files (.el, Makefile, Dockerfile) never land there,
// so all these paths should 404.
test.describe('Information leakage — source files not served', () => {
const leakyPaths = [
'/src/main.el',
'/.env',
'/Dockerfile.stage',
'/runtime/el_runtime.c',
'/.gitea/workflows/stage.yaml',
'/dist/neuron-landing',
];
for (const path of leakyPaths) {
test(`${path} returns 404`, async () => {
const r = await get(path);
expect(r.status).toBe(404);
});
}
});
// ── /api/soul-health — internal-only diagnostic ───────────────────────────────
// Returns 404 without the X-Internal: true header.
// Returns 200 with the header (allows in-container health probing).
test.describe('Soul health — internal gate', () => {
test('Returns 404 without X-Internal header', async () => {
const r = await get('/api/soul-health');
expect(r.status).toBe(404);
});
test('Returns 200 with X-Internal: true and includes soul_url', async () => {
const r = await get('/api/soul-health', { 'X-Internal': 'true' });
expect(r.status).toBe(200);
// The response embeds raw probe output which may contain literal newlines
// inside JSON strings (invalid JSON). Check via text search to avoid
// JSON.parse failure on the control characters.
const text = await r.text();
expect(text).toContain('"soul_url"');
expect(text).toMatch(/soul_url.*https?:\/\//);
});
});
// ── Path traversal ────────────────────────────────────────────────────────────
// The El runtime only serves files from whitelisted paths (src/assets/,
// src/shares/, src/js/). Any traversal attempt resolves to 404 — the
// runtime never reads outside its served directories.
test.describe('Path traversal blocked', () => {
const traversals = [
'/assets/../../../etc/passwd',
'/assets/%2e%2e%2f%2e%2e%2fetc%2fpasswd',
'/js/../../../etc/passwd',
];
for (const path of traversals) {
test(`Traversal blocked: ${path}`, async () => {
const r = await get(path);
expect(r.status).toBe(404);
const text = await r.text();
// Must not contain any /etc/passwd content
expect(text).not.toContain('root:');
});
}
});
// ── Input validation — /api/demo message length cap ──────────────────────────
// Messages over 8000 chars are rejected before any auth or LLM call.
test.describe('Input validation', () => {
test('Oversized message (>8000 chars) is rejected with error', async () => {
const r = await post('/api/demo', {
message: 'A'.repeat(10000),
access_token: 'test',
});
const body = await r.json() as Record<string, unknown>;
// Length guard fires before auth check in server code
expect(typeof body.error).toBe('string');
expect((body.error as string).toLowerCase()).toMatch(/long|length|8000/i);
});
});