1eeb8df04b
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.
215 lines
8.9 KiB
TypeScript
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);
|
|
});
|
|
});
|