chore: add security tests and SECURITY.md (previously untracked)
- 9 backend security test files (auth, headers, rate-limit, middleware order, smoke, validation, WS auth, integration bootstrap) - 1 e2e test (clear-preference) - SECURITY.md policy doc These files powered the 369-test suite but were never committed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9fb8ce34ec
commit
15a5cfcc61
+116
@@ -0,0 +1,116 @@
|
||||
# Security
|
||||
|
||||
## Threat Model
|
||||
|
||||
This server is designed for **local and self-hosted use** — it runs on the same machine as your AI agent and browser. The primary threat surface is:
|
||||
|
||||
1. A malicious website making cross-origin requests to the canvas API (CSRF / drive-by reads or writes).
|
||||
2. A compromised or untrusted network exposing the canvas port to other hosts.
|
||||
3. Malicious input (oversized payloads, prototype pollution, injection) reaching route handlers.
|
||||
|
||||
The threat model does **not** cover:
|
||||
- An attacker with local OS access (they can read the SQLite file directly).
|
||||
- Server-side request forgery from within the canvas server itself.
|
||||
|
||||
---
|
||||
|
||||
## Mitigations
|
||||
|
||||
### CORS — `corsMiddleware` (`src/security.ts`)
|
||||
|
||||
Restricts cross-origin requests to an explicit allowlist (`ALLOWED_ORIGINS` env var, defaults to `localhost:3000` / `127.0.0.1:3000`). Requests with no `Origin` header (MCP stdio, curl, same-origin) are always allowed.
|
||||
|
||||
### WebSocket origin check — `verifyWsClient` (`src/security.ts`)
|
||||
|
||||
WebSocket upgrades are verified against the same allowlist before the connection is established. Rejects browser-originated connections from unlisted origins.
|
||||
|
||||
### WebSocket auth challenge-response
|
||||
|
||||
When `EXCALIDRAW_API_KEY` is set, the server immediately sends `{ type: "auth_required" }` after each new WebSocket connection. The client must respond with a `hello` message containing `{ type: "hello", apiKey: "<key>", ... }` within 5 seconds. If the key is missing, wrong, or the timeout fires, the server closes the connection with close code 4001. All other message types are silently dropped until auth succeeds. When auth is disabled, the `hello` handshake proceeds without key validation.
|
||||
|
||||
### Auth bootstrap — `GET /` key injection
|
||||
|
||||
When `EXCALIDRAW_API_KEY` is set, `GET /` injects `<script>window.__EXCALIDRAW_API_KEY__=…</script>` into the served HTML before `</head>`. The browser canvas reads this value at startup and includes it in the WebSocket `hello` message automatically, so users don't need to configure the key in the browser separately. The value is JSON-encoded with `<` escaped to `\u003c` to prevent script injection.
|
||||
|
||||
### API key auth — `apiKeyAuth` (`src/security.ts`)
|
||||
|
||||
When `EXCALIDRAW_API_KEY` is set, all `/api/*` routes require the header `X-API-Key: <key>`. Disabled by default for backward compatibility and zero-config local use. The `/health` endpoint is always exempt.
|
||||
|
||||
### Security headers — `helmetMiddleware` (`src/security.ts`)
|
||||
|
||||
Sets `X-Content-Type-Options: nosniff`, `X-Frame-Options`, `X-DNS-Prefetch-Control`, and removes `X-Powered-By`. CSP and COEP are intentionally disabled to allow Excalidraw's React bundle (inline scripts/styles).
|
||||
|
||||
### Rate limiting — `generalRateLimit` / `destructiveRateLimit` / `writeBurstLimit` (`src/security.ts`)
|
||||
|
||||
Three limiters apply, all returning `RateLimit-*` headers (draft-7) so clients can self-throttle:
|
||||
|
||||
| Limiter | Applied to | Default | Override env var |
|
||||
|---------|-----------|---------|-----------------|
|
||||
| `generalRateLimit` | All `/api/*` routes | 100 req / 15 min | `EXCALIDRAW_RATE_LIMIT_GENERAL_MAX` |
|
||||
| `destructiveRateLimit` | `DELETE /api/elements/clear` | 10 req / 1 min | `EXCALIDRAW_RATE_LIMIT_DESTRUCTIVE_MAX` |
|
||||
| `writeBurstLimit` | `POST /api/elements/sync`, `POST /api/elements/sync/v2` | 10 req / 1 min | `EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX` |
|
||||
|
||||
Ceilings are read from env vars at server start. The E2E test harness sets them to high values via `playwright.config.ts` so tests are not self-throttled.
|
||||
|
||||
### Confirmation guard — `requireConfirm` (`src/security.ts`)
|
||||
|
||||
The `DELETE /api/elements/clear` endpoint requires `?confirm=true`. Prevents accidental or CSRF-triggered canvas wipes.
|
||||
|
||||
### Body size limits (`src/server.ts`)
|
||||
|
||||
- Default body limit: **100 KB** (standard API requests).
|
||||
- Batch/sync endpoints: **5 MB** (element arrays and sync payloads).
|
||||
- Oversized payloads return `413 Payload Too Large`.
|
||||
|
||||
### Prototype pollution guard — `sanitizeBody` (`src/security.ts`)
|
||||
|
||||
Rejects any request body containing `__proto__`, `constructor`, or `prototype` as object keys. Returns `400 Bad Request` before any route handler sees the data.
|
||||
|
||||
### Search query sanitization — `sanitizeSearchQuery` (`src/security.ts`)
|
||||
|
||||
`GET /api/elements/search?q=…` passes the query through `sanitizeSearchQuery` before handing it to the SQLite FTS5 engine. The function rejects queries that contain FTS5 operators (`AND`, `OR`, `NOT`, `NEAR/N`), double-quote quoting constructs, or special characters (`*`, `(`, `)`, `{`, `}`, `^`). This prevents malformed FTS5 syntax from bubbling up as SQLite parse errors and closes a narrow injection surface into the FTS virtual table.
|
||||
|
||||
### Mermaid input validation — `validateMermaidInput` (`src/security.ts`)
|
||||
|
||||
- Diagram string: max **50 KB** (prevents DoS via large Mermaid parse).
|
||||
- Config object: max **10 keys** (prevents unbounded config expansion).
|
||||
|
||||
### Error handling (`src/server.ts`)
|
||||
|
||||
The global error handler never exposes stack traces, file paths, or `node_modules` references in responses. 500 errors return the generic message `"Internal server error"`. Non-500 errors surface the error message only.
|
||||
|
||||
### Docker host binding (`Dockerfile.canvas`, `docker-compose.yml`)
|
||||
|
||||
`HOST=0.0.0.0` inside Docker is intentional: the container binds all interfaces, but the port is only reachable via the published port mapping. For local non-Docker use, the server defaults to `127.0.0.1` (loopback only).
|
||||
|
||||
---
|
||||
|
||||
## Pinned Dependencies
|
||||
|
||||
Security-critical packages are pinned to exact versions (no `^` range) to prevent silent upgrades introducing regressions:
|
||||
|
||||
| Package | Reason |
|
||||
|---------|--------|
|
||||
| `helmet` | Security headers — pin to known-good config |
|
||||
| `express-rate-limit` | Rate limiter — header format changes between major versions |
|
||||
| `cors` | CORS policy enforcement |
|
||||
| `express` | HTTP server — patch releases may change middleware behavior |
|
||||
| `ws` | WebSocket server — security patches applied selectively |
|
||||
| `better-sqlite3` | Native module — ABI compatibility with pinned Node.js |
|
||||
| `zod` | Input validation — schema breaking changes between minors |
|
||||
| `@modelcontextprotocol/sdk` | Protocol — pin to tested version |
|
||||
|
||||
---
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
Open an issue in the project repository. For sensitive disclosures, contact the maintainer directly via GitHub.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **No HTTPS**: The canvas server speaks plain HTTP. Use a reverse proxy (nginx, Caddy) with TLS for any non-localhost deployment.
|
||||
- **Single shared API key**: There is no per-user or per-tenant auth. The key protects the entire API surface equally.
|
||||
- **Rate limits are in-memory**: They reset on process restart and are not shared across multiple server instances.
|
||||
- **SQLite is not encrypted**: The database file is stored in plaintext. Apply OS-level encryption if needed.
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import {
|
||||
initDb,
|
||||
closeDb,
|
||||
clearElements,
|
||||
ensureTenant,
|
||||
getDefaultProjectForTenant,
|
||||
setActiveTenant,
|
||||
setElement,
|
||||
} from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import WebSocket from 'ws';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let port: number;
|
||||
let startCanvasServer: () => Promise<void>;
|
||||
let stopCanvasServer: () => Promise<void>;
|
||||
const frontendDir = path.join(process.cwd(), 'dist/frontend');
|
||||
const frontendHtmlPath = path.join(frontendDir, 'index.html');
|
||||
let originalFrontendHtml: string | null = null;
|
||||
let hadFrontendHtml = false;
|
||||
|
||||
function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs);
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === type) {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', handler);
|
||||
resolve(msg);
|
||||
}
|
||||
};
|
||||
ws.on('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
function connectAndCollect(waitMs = 200): Promise<{ ws: WebSocket; messages: any[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const ws = new WebSocket(`ws://localhost:${port}`);
|
||||
ws.on('message', (raw) => messages.push(JSON.parse(raw.toString())));
|
||||
ws.on('open', () => setTimeout(() => resolve({ ws, messages }), waitMs));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3500 + Math.floor(Math.random() * 100);
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
process.env.EXCALIDRAW_API_KEY = 'integration-secret';
|
||||
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-auth-integration-${Date.now()}.db`);
|
||||
initDb(dbPath);
|
||||
|
||||
hadFrontendHtml = fs.existsSync(frontendHtmlPath);
|
||||
originalFrontendHtml = hadFrontendHtml ? fs.readFileSync(frontendHtmlPath, 'utf8') : null;
|
||||
fs.mkdirSync(frontendDir, { recursive: true });
|
||||
fs.writeFileSync(frontendHtmlPath, '<!doctype html><html><head><title>Integration</title></head><body><div id="root"></div></body></html>');
|
||||
|
||||
const mod = await import('../../src/server.js');
|
||||
startCanvasServer = mod.startCanvasServer;
|
||||
stopCanvasServer = mod.stopCanvasServer;
|
||||
await startCanvasServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
await stopCanvasServer();
|
||||
closeDb();
|
||||
if (hadFrontendHtml && originalFrontendHtml !== null) {
|
||||
fs.writeFileSync(frontendHtmlPath, originalFrontendHtml);
|
||||
} else {
|
||||
try { fs.unlinkSync(frontendHtmlPath); } catch {}
|
||||
}
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActiveTenant('default');
|
||||
clearElements();
|
||||
});
|
||||
|
||||
describe('Auth bootstrap integration', () => {
|
||||
it('serves injected HTML, authenticates over WS, and reads scoped REST data', async () => {
|
||||
ensureTenant('integration-a', 'Integration A', 'workspace/integration-a');
|
||||
setActiveTenant('integration-a');
|
||||
const projectId = getDefaultProjectForTenant('integration-a');
|
||||
setElement('integration-el', {
|
||||
id: 'integration-el',
|
||||
type: 'rectangle',
|
||||
x: 25,
|
||||
y: 30,
|
||||
width: 120,
|
||||
height: 80,
|
||||
version: 1,
|
||||
} as ServerElement, projectId);
|
||||
|
||||
const rootRes = await fetch(`http://localhost:${port}/`);
|
||||
expect(rootRes.status).toBe(200);
|
||||
const html = await rootRes.text();
|
||||
expect(html).toContain('window.__EXCALIDRAW_API_KEY__="integration-secret"');
|
||||
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(message => message.type === 'auth_required')).toBe(true);
|
||||
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', apiKey: 'integration-secret' }));
|
||||
const ack = await ackPromise;
|
||||
|
||||
expect(ack.tenantId).toBe('integration-a');
|
||||
expect(ack.projectId).toBe(projectId);
|
||||
expect(ack.elements.map((element: any) => element.id)).toContain('integration-el');
|
||||
|
||||
const listRes = await fetch(`http://localhost:${port}/api/elements`, {
|
||||
headers: {
|
||||
'X-API-Key': 'integration-secret',
|
||||
'X-Tenant-Id': 'integration-a',
|
||||
},
|
||||
});
|
||||
expect(listRes.status).toBe(200);
|
||||
const listBody = await listRes.json() as { count: number; elements: { id: string }[] };
|
||||
expect(listBody.count).toBe(1);
|
||||
expect(listBody.elements[0].id).toBe('integration-el');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('authenticated WS clients receive tenant_switched after a keyed REST switch', async () => {
|
||||
ensureTenant('integration-b', 'Integration B', 'workspace/integration-b');
|
||||
ensureTenant('integration-c', 'Integration C', 'workspace/integration-c');
|
||||
setActiveTenant('integration-b');
|
||||
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(message => message.type === 'auth_required')).toBe(true);
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', apiKey: 'integration-secret' }));
|
||||
const ack = await ackPromise;
|
||||
expect(ack.tenantId).toBe('integration-b');
|
||||
|
||||
const switchPromise = waitForMessageOfType(ws, 'tenant_switched');
|
||||
const switchRes = await fetch(`http://localhost:${port}/api/tenant/active`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'integration-secret',
|
||||
},
|
||||
body: JSON.stringify({ tenantId: 'integration-c' }),
|
||||
});
|
||||
|
||||
expect(switchRes.status).toBe(200);
|
||||
const switched = await switchPromise;
|
||||
expect(switched.tenant.id).toBe('integration-c');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
const frontendDir = path.join(process.cwd(), 'dist/frontend');
|
||||
const frontendHtmlPath = path.join(frontendDir, 'index.html');
|
||||
let originalFrontendHtml: string | null = null;
|
||||
let hadFrontendHtml = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
hadFrontendHtml = fs.existsSync(frontendHtmlPath);
|
||||
originalFrontendHtml = hadFrontendHtml ? fs.readFileSync(frontendHtmlPath, 'utf8') : null;
|
||||
fs.mkdirSync(frontendDir, { recursive: true });
|
||||
fs.writeFileSync(frontendHtmlPath, '<!doctype html><html><head><title>Test</title></head><body><div id="root"></div></body></html>');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
delete process.env.ALLOWED_ORIGINS;
|
||||
closeDb();
|
||||
if (hadFrontendHtml && originalFrontendHtml !== null) {
|
||||
fs.writeFileSync(frontendHtmlPath, originalFrontendHtml);
|
||||
} else {
|
||||
try { fs.unlinkSync(frontendHtmlPath); } catch {}
|
||||
}
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── API Key Auth ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Auth — disabled (no env var)', () => {
|
||||
it('allows GET /api/elements without API key', async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('allows DELETE /api/elements/clear without API key', async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
const res = await request(app).delete('/api/elements/clear?confirm=true');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Auth — enabled (EXCALIDRAW_API_KEY set)', () => {
|
||||
it('rejects GET /api/elements without key → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects GET /api/elements with wrong key → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'wrong-key');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows GET /api/elements with correct key → 200', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'test-secret');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects POST /api/elements without key → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects DELETE /api/elements/clear without key → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app).delete('/api/elements/clear?confirm=true');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('health endpoint is exempt from auth → 200', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects empty X-API-Key header → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', '');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MCP → Canvas inter-service auth (trust boundary A) ─────────────────────
|
||||
// When EXCALIDRAW_API_KEY is set, the canvas REST API must reject requests that
|
||||
// don't include the key — including any inter-service caller (MCP or other).
|
||||
// This validates that the canvas enforces auth at its own boundary regardless
|
||||
// of the caller; the MCP-side fix (forwarding X-API-Key in canvasHeaders) is
|
||||
// verified by ensuring the canvas correctly accepts/rejects the header.
|
||||
|
||||
describe('MCP → Canvas auth boundary: canvas enforces key on all callers', () => {
|
||||
it('rejects inter-service request with no X-API-Key → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'inter-service-secret';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'default');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts inter-service request with correct X-API-Key → 200', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'inter-service-secret';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'default')
|
||||
.set('X-API-Key', 'inter-service-secret');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects inter-service request with wrong X-API-Key → 401', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'inter-service-secret';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'default')
|
||||
.set('X-API-Key', 'wrong-key');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CORS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('CORS — origin restriction', () => {
|
||||
it('allows requests with no Origin header', async () => {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('reflects localhost:3000 as allowed origin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('Origin', 'http://localhost:3000');
|
||||
expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('reflects 127.0.0.1:3000 as allowed origin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('Origin', 'http://127.0.0.1:3000');
|
||||
expect(res.headers['access-control-allow-origin']).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
|
||||
it('does NOT reflect untrusted origin in ACAO header', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('Origin', 'https://evil.com');
|
||||
const acao = res.headers['access-control-allow-origin'];
|
||||
expect(acao).not.toBe('https://evil.com');
|
||||
expect(acao).not.toBe('*');
|
||||
});
|
||||
|
||||
it('allows custom origin from ALLOWED_ORIGINS env var', async () => {
|
||||
process.env.ALLOWED_ORIGINS = 'http://myapp.local:4000,http://localhost:3000';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('Origin', 'http://myapp.local:4000');
|
||||
expect(res.headers['access-control-allow-origin']).toBe('http://myapp.local:4000');
|
||||
});
|
||||
|
||||
it('rejects origin not in custom ALLOWED_ORIGINS list', async () => {
|
||||
process.env.ALLOWED_ORIGINS = 'http://myapp.local:4000';
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('Origin', 'http://localhost:3000');
|
||||
const acao = res.headers['access-control-allow-origin'];
|
||||
expect(acao).not.toBe('http://localhost:3000');
|
||||
expect(acao).not.toBe('*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey — timing-safe comparison', () => {
|
||||
it('accepts correct key', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'secure-key-abc123';
|
||||
const { validateApiKey } = await import('../../src/security.js');
|
||||
expect(validateApiKey('secure-key-abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong key', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'secure-key-abc123';
|
||||
const { validateApiKey } = await import('../../src/security.js');
|
||||
expect(validateApiKey('wrong-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects key that is a prefix of the correct key', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'secure-key-abc123';
|
||||
const { validateApiKey } = await import('../../src/security.js');
|
||||
expect(validateApiKey('secure-key-abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects key that is a superstring of the correct key', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'secure-key-abc123';
|
||||
const { validateApiKey } = await import('../../src/security.js');
|
||||
expect(validateApiKey('secure-key-abc123EXTRA')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects undefined', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'secure-key-abc123';
|
||||
const { validateApiKey } = await import('../../src/security.js');
|
||||
expect(validateApiKey(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows anything when auth is disabled', async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
const { validateApiKey } = await import('../../src/security.js');
|
||||
expect(validateApiKey(undefined)).toBe(true);
|
||||
expect(validateApiKey('anything')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET / frontend auth bootstrap', () => {
|
||||
it('injects __EXCALIDRAW_API_KEY__ into the served HTML when auth is enabled', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('window.__EXCALIDRAW_API_KEY__="test-secret"');
|
||||
});
|
||||
|
||||
it('does not inject __EXCALIDRAW_API_KEY__ when auth is disabled', async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).not.toContain('__EXCALIDRAW_API_KEY__');
|
||||
});
|
||||
|
||||
it('injects the current EXCALIDRAW_API_KEY value', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'rotated-secret';
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('window.__EXCALIDRAW_API_KEY__="rotated-secret"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-headers-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Security Headers ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Security headers (helmet)', () => {
|
||||
it('sets X-Content-Type-Options: nosniff', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('sets X-Frame-Options header', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.headers['x-frame-options']).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets X-DNS-Prefetch-Control header', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.headers['x-dns-prefetch-control']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT expose X-Powered-By: Express', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.headers['x-powered-by']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Leakage Prevention ────────────────────────────────────────────────
|
||||
|
||||
describe('Error responses do not leak internals', () => {
|
||||
it('404 response does not contain stack traces', async () => {
|
||||
const res = await request(app).get('/api/nonexistent-endpoint-xyz');
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toMatch(/at\s+\w+\s+\(/); // No stack frames
|
||||
expect(body).not.toMatch(/node_modules/);
|
||||
expect(body).not.toMatch(/\/Users\//);
|
||||
expect(body).not.toMatch(/\/home\//);
|
||||
});
|
||||
|
||||
it('500 error response uses generic message, not stack', async () => {
|
||||
// Trigger the global error handler with an invalid route that causes a crash
|
||||
// (we test error handler behavior via the sanitized message)
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"type":"rectangle","x":0,"y":0}'); // valid, won't trigger 500
|
||||
// Just verify non-500 responses also don't leak internals
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toMatch(/at\s+\w+\s+\(/);
|
||||
});
|
||||
|
||||
it('validation error response does not leak file paths', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"__proto__":{"admin":true},"type":"rectangle"}');
|
||||
expect(res.status).toBe(400);
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toMatch(/\/Users\//);
|
||||
expect(body).not.toMatch(/node_modules/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenant Validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Tenant switching validation', () => {
|
||||
it('PUT /api/tenant/active rejects non-existent tenant → 400', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'totally-fake-tenant-that-does-not-exist' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('PUT /api/tenant/active with missing tenantId → 400', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-middleware-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
app.set('trust proxy', 1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Middleware order', () => {
|
||||
it('bad API key + oversized body returns 401, not 413', async () => {
|
||||
const bigText = 'x'.repeat(150 * 1024);
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-API-Key', 'wrong-key')
|
||||
.set('X-Forwarded-For', '10.20.0.1')
|
||||
.send(JSON.stringify({ type: 'text', x: 0, y: 0, width: 100, height: 50, text: bigText }));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('bad API key returns 401 with rate-limit headers', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'wrong-key')
|
||||
.set('X-Forwarded-For', '10.20.0.2');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers).toHaveProperty('ratelimit-policy');
|
||||
});
|
||||
|
||||
it('401 with bad API key is still rate-limited', async () => {
|
||||
const ip = '10.20.0.3';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'wrong-key')
|
||||
.set('X-Forwarded-For', ip);
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'wrong-key')
|
||||
.set('X-Forwarded-For', ip);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it('valid API key + oversized body returns 413', async () => {
|
||||
const bigText = 'x'.repeat(150 * 1024);
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-API-Key', 'test-secret')
|
||||
.set('X-Forwarded-For', '10.20.0.4')
|
||||
.send(JSON.stringify({ type: 'text', x: 0, y: 0, width: 100, height: 50, text: bigText }));
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-ratelimit-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
app.set('trust proxy', 1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Clear Canvas Confirmation ───────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/elements/clear — confirmation token', () => {
|
||||
it('rejects clear without confirm=true query param → 400', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects clear with confirm=false → 400', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear?confirm=false');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows clear with confirm=true → 200', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear?confirm=true');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Payload Size Limits ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Payload size limits', () => {
|
||||
it('rejects POST /api/elements with body > 100KB → 413', async () => {
|
||||
const bigText = 'x'.repeat(150 * 1024); // 150KB
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ type: 'text', x: 0, y: 0, width: 100, height: 50, text: bigText }));
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
it('accepts POST /api/elements with body within limit → not 413', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(res.status).not.toBe(413);
|
||||
});
|
||||
|
||||
it('rejects POST /api/elements/batch with body > 5MB → 413', async () => {
|
||||
// Build a payload just over 5MB
|
||||
const elements = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `el-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 10, y: 0, width: 100, height: 50,
|
||||
// Pad each element with ~600KB of label text
|
||||
label: 'x'.repeat(600 * 1024),
|
||||
}));
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ elements }));
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rate Limiting ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Rate limiting — destructive endpoints', () => {
|
||||
it('returns 429 after exceeding clear rate limit', async () => {
|
||||
// Exhaust the per-minute limit for destructive ops (default 10)
|
||||
const limit = 10;
|
||||
for (let i = 0; i < limit; i++) {
|
||||
await request(app).delete('/api/elements/clear?confirm=true');
|
||||
}
|
||||
const res = await request(app).delete('/api/elements/clear?confirm=true');
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it('returns RateLimit headers on destructive endpoint', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear?confirm=true');
|
||||
// express-rate-limit draft-7 sets ratelimit-policy on every response
|
||||
expect(res.headers).toHaveProperty('ratelimit-policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate limiting — sync endpoints', () => {
|
||||
it('returns 429 after exceeding /api/elements/sync write-burst limit', async () => {
|
||||
const ip = '10.10.0.1';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.send({ elements: [], timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.send({ elements: [], timestamp: new Date().toISOString() });
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it('returns 429 after exceeding /api/elements/sync/v2 write-burst limit', async () => {
|
||||
const ip = '10.10.0.2';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it('sync 429 responses include rate-limit headers', async () => {
|
||||
const ip = '10.10.0.3';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.send({ elements: [], timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.send({ elements: [], timestamp: new Date().toISOString() });
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers).toHaveProperty('ratelimit-policy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
const frontendDir = path.join(process.cwd(), 'dist/frontend');
|
||||
const frontendHtmlPath = path.join(frontendDir, 'index.html');
|
||||
let originalFrontendHtml: string | null = null;
|
||||
let hadFrontendHtml = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-smoke-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
hadFrontendHtml = fs.existsSync(frontendHtmlPath);
|
||||
originalFrontendHtml = hadFrontendHtml ? fs.readFileSync(frontendHtmlPath, 'utf8') : null;
|
||||
fs.mkdirSync(frontendDir, { recursive: true });
|
||||
fs.writeFileSync(frontendHtmlPath, '<!doctype html><html><head><title>Smoke</title></head><body><div id="root"></div></body></html>');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
closeDb();
|
||||
if (hadFrontendHtml && originalFrontendHtml !== null) {
|
||||
fs.writeFileSync(frontendHtmlPath, originalFrontendHtml);
|
||||
} else {
|
||||
try { fs.unlinkSync(frontendHtmlPath); } catch {}
|
||||
}
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Smoke checks', () => {
|
||||
it('serves the health endpoint and frontend shell', async () => {
|
||||
const healthRes = await request(app).get('/health');
|
||||
expect(healthRes.status).toBe(200);
|
||||
expect(healthRes.body.status).toBe('healthy');
|
||||
|
||||
const rootRes = await request(app).get('/');
|
||||
expect(rootRes.status).toBe(200);
|
||||
expect(rootRes.text).toContain('<div id="root"></div>');
|
||||
});
|
||||
|
||||
it('supports a keyed create-list-delete smoke flow', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'smoke-secret';
|
||||
|
||||
const createRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-API-Key', 'smoke-secret')
|
||||
.send({ id: 'smoke-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(createRes.status).toBe(200);
|
||||
|
||||
const listRes = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'smoke-secret');
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(listRes.body.count).toBe(1);
|
||||
expect(listRes.body.elements[0].id).toBe('smoke-el');
|
||||
|
||||
const searchRes = await request(app)
|
||||
.get('/api/elements/search')
|
||||
.set('X-API-Key', 'smoke-secret')
|
||||
.query({ q: 'rectangle' });
|
||||
expect(searchRes.status).toBe(200);
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete('/api/elements/smoke-el')
|
||||
.set('X-API-Key', 'smoke-secret');
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
const finalListRes = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'smoke-secret');
|
||||
expect(finalListRes.body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-validation-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Prototype Pollution ─────────────────────────────────────────────────────
|
||||
// The sanitizeBody middleware strips dangerous keys from req.body and returns
|
||||
// 400 when they are detected, so that nothing reaches the route handlers.
|
||||
|
||||
describe('Prototype pollution prevention', () => {
|
||||
it('rejects __proto__ key in POST /api/elements → 400', async () => {
|
||||
// Send raw JSON string (real attack vector — not via JS object)
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"__proto__":{"admin":true},"type":"rectangle","x":0,"y":0,"width":100,"height":50}');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects constructor key in POST /api/elements → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"constructor":{"name":"pwned"},"type":"rectangle","x":0,"y":0,"width":100,"height":50}');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects __proto__ key in PUT /api/elements/:id → 400', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/elements/some-id')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"__proto__":{"admin":true},"x":10,"y":10}');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('allows clean body in POST /api/elements → not 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(res.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mermaid Injection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Mermaid diagram validation', () => {
|
||||
it('rejects diagram > 50KB → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: 'graph TD\n' + 'A-->B\n'.repeat(9000) }); // ~54KB > 50KB limit
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects config with > 10 keys → 400', async () => {
|
||||
const config: Record<string, number> = {};
|
||||
for (let i = 0; i < 15; i++) config[`key${i}`] = i;
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: 'graph TD\nA-->B', config });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts valid small diagram → not 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: 'graph TD\nA-->B' });
|
||||
// 200 (no WS client) or 503 (no frontend connected) — both valid
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(res.status).not.toBe(413);
|
||||
});
|
||||
|
||||
it('rejects non-string mermaid diagram → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: 12345 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search Filter Sanitization ──────────────────────────────────────────────
|
||||
|
||||
describe('Search filter sanitization', () => {
|
||||
it('handles empty search query without crashing → 200', async () => {
|
||||
const res = await request(app).get('/api/elements/search');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('search with unmatched quote returns 400, not 500', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements/search')
|
||||
.query({ q: '"unterminated' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid search query');
|
||||
});
|
||||
|
||||
it("search with bare FTS operator 'AND' returns 400, not 500", async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements/search')
|
||||
.query({ q: 'AND' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid search query');
|
||||
});
|
||||
|
||||
it("search with 'NEAR/3' returns 400, not 500", async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements/search')
|
||||
.query({ q: 'NEAR/3' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid search query');
|
||||
});
|
||||
|
||||
it("search 400 response does not contain 'fts5' or 'sqlite' in error message", async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements/search')
|
||||
.query({ q: 'AND' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error).toLowerCase()).not.toContain('fts5');
|
||||
expect(String(res.body.error).toLowerCase()).not.toContain('sqlite');
|
||||
});
|
||||
|
||||
it('search with valid query still returns 200', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(createRes.status).toBe(200);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/elements/search')
|
||||
.query({ q: 'rectangle' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import Validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/import validation', () => {
|
||||
it('rejects non-array elements in import body → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/import')
|
||||
.send({ elements: 'not-an-array' });
|
||||
// 400 = validation rejected, 404 = endpoint doesn't exist — both are safe
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import {
|
||||
initDb,
|
||||
closeDb,
|
||||
clearElements,
|
||||
ensureTenant,
|
||||
getDefaultProjectForTenant,
|
||||
setActiveTenant,
|
||||
setElement,
|
||||
} from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import WebSocket from 'ws';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let port: number;
|
||||
let startCanvasServer: () => Promise<void>;
|
||||
let stopCanvasServer: () => Promise<void>;
|
||||
|
||||
function connectAndCollect(waitMs = 300): Promise<{ ws: WebSocket; messages: any[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const ws = new WebSocket(`ws://localhost:${port}`);
|
||||
ws.on('message', (raw) => messages.push(JSON.parse(raw.toString())));
|
||||
ws.on('open', () => setTimeout(() => resolve({ ws, messages }), waitMs));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs);
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === type) {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', handler);
|
||||
resolve(msg);
|
||||
}
|
||||
};
|
||||
ws.on('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(ws: WebSocket, timeoutMs = 7000): Promise<{ code: number; reason: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timeout waiting for close')), timeoutMs);
|
||||
ws.on('close', (code, reason) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collectMessagesFor(ws: WebSocket, durationMs: number): Promise<any[]> {
|
||||
return new Promise((resolve) => {
|
||||
const messages: any[] = [];
|
||||
const handler = (data: WebSocket.RawData) => messages.push(JSON.parse(data.toString()));
|
||||
ws.on('message', handler);
|
||||
setTimeout(() => {
|
||||
ws.off('message', handler);
|
||||
resolve(messages);
|
||||
}, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3400 + Math.floor(Math.random() * 100);
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-ws-auth-test-${Date.now()}.db`);
|
||||
initDb(dbPath);
|
||||
|
||||
const mod = await import('../../src/server.js');
|
||||
startCanvasServer = mod.startCanvasServer;
|
||||
stopCanvasServer = mod.stopCanvasServer;
|
||||
await startCanvasServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
await stopCanvasServer();
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
setActiveTenant('default');
|
||||
clearElements();
|
||||
});
|
||||
|
||||
describe('WebSocket auth gate', () => {
|
||||
it('auth enabled: WS connection receives auth_required and no element data before hello', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
|
||||
const types = messages.map(m => m.type);
|
||||
expect(types).toContain('auth_required');
|
||||
expect(types).not.toContain('tenant_switched');
|
||||
expect(types).not.toContain('initial_elements');
|
||||
expect(types).not.toContain('files_added');
|
||||
expect(types).not.toContain('sync_status');
|
||||
|
||||
ws.terminate();
|
||||
});
|
||||
|
||||
it('auth enabled: WS closes with 4001 if no valid hello arrives within 5s', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
|
||||
|
||||
const authFailedPromise = waitForMessageOfType(ws, 'auth_failed', 7000);
|
||||
const closePromise = waitForClose(ws, 7000);
|
||||
const [authFailed, close] = await Promise.all([authFailedPromise, closePromise]);
|
||||
|
||||
expect(authFailed.reason).toBe('timeout');
|
||||
expect(close.code).toBe(4001);
|
||||
});
|
||||
|
||||
it('auth enabled: hello with valid apiKey and no tenantId bootstraps the active tenant', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
ensureTenant('boot-tenant', 'Boot Tenant', 'workspace/boot-tenant');
|
||||
setActiveTenant('boot-tenant');
|
||||
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
|
||||
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 5000);
|
||||
ws.send(JSON.stringify({ type: 'hello', apiKey: 'test-secret' }));
|
||||
const ack = await ackPromise;
|
||||
|
||||
expect(ack.tenantId).toBe('boot-tenant');
|
||||
expect(ack.tenant.id).toBe('boot-tenant');
|
||||
expect(ack.projectId).toBe(getDefaultProjectForTenant('boot-tenant'));
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('auth enabled: hello with wrong apiKey sends auth_failed and closes 4001', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
|
||||
|
||||
const authFailedPromise = waitForMessageOfType(ws, 'auth_failed', 5000);
|
||||
const closePromise = waitForClose(ws, 5000);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'hello', apiKey: 'wrong-key' }));
|
||||
|
||||
const [authFailed, close] = await Promise.all([authFailedPromise, closePromise]);
|
||||
expect(authFailed.reason).toBe('invalid_key');
|
||||
expect(close.code).toBe(4001);
|
||||
});
|
||||
|
||||
it('auth enabled: hello with valid apiKey and unknown tenantId sends error and no elements', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
|
||||
|
||||
const errorPromise = waitForMessageOfType(ws, 'error', 5000);
|
||||
ws.send(JSON.stringify({ type: 'hello', apiKey: 'test-secret', tenantId: 'missing-tenant' }));
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toBe('Unknown tenant');
|
||||
|
||||
const trailingMessages = await collectMessagesFor(ws, 300);
|
||||
expect(trailingMessages.some(msg => msg.type === 'hello_ack')).toBe(false);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('auth enabled: invalid projectId falls back to the tenant default project', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'test-secret';
|
||||
ensureTenant('scope-a', 'Scope A', 'workspace/scope-a');
|
||||
ensureTenant('scope-b', 'Scope B', 'workspace/scope-b');
|
||||
|
||||
const defaultProjectId = getDefaultProjectForTenant('scope-a');
|
||||
const otherProjectId = getDefaultProjectForTenant('scope-b');
|
||||
|
||||
setElement('scope-a-element', {
|
||||
id: 'scope-a-element',
|
||||
type: 'rectangle',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
version: 1,
|
||||
} as ServerElement, defaultProjectId);
|
||||
setElement('scope-b-element', {
|
||||
id: 'scope-b-element',
|
||||
type: 'ellipse',
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
version: 1,
|
||||
} as ServerElement, otherProjectId);
|
||||
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
|
||||
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 5000);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
apiKey: 'test-secret',
|
||||
tenantId: 'scope-a',
|
||||
projectId: otherProjectId,
|
||||
}));
|
||||
|
||||
const ack = await ackPromise;
|
||||
expect(ack.tenantId).toBe('scope-a');
|
||||
expect(ack.projectId).toBe(defaultProjectId);
|
||||
expect(ack.elements.map((element: any) => element.id)).toContain('scope-a-element');
|
||||
expect(ack.elements.map((element: any) => element.id)).not.toContain('scope-b-element');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('auth disabled: WS connection receives tenant_switched and initial_elements immediately', async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
const { ws, messages } = await connectAndCollect();
|
||||
|
||||
const types = messages.map(m => m.type);
|
||||
expect(types).toContain('tenant_switched');
|
||||
expect(types).toContain('initial_elements');
|
||||
expect(types).toContain('sync_status');
|
||||
expect(types).not.toContain('auth_required');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('auth disabled: hello without apiKey still works and receives hello_ack', async () => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
const { ws } = await connectAndCollect();
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 5000);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
|
||||
const ack = await ackPromise;
|
||||
expect(ack.tenantId).toBe('default');
|
||||
expect(Array.isArray(ack.elements)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const API = 'http://127.0.0.1:3100';
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
|
||||
data: { value: 'false' },
|
||||
});
|
||||
await request.delete(`${API}/api/elements/clear?confirm=true`);
|
||||
});
|
||||
|
||||
test.describe('Clear canvas preference', () => {
|
||||
test('checking "Don\'t ask again" persists and skips the next confirmation dialog', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'pref-el-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
|
||||
await page.locator('button:has-text("Clear Canvas")').click();
|
||||
await expect(page.locator('.confirm-dialog')).toBeVisible();
|
||||
await page.locator('.confirm-checkbox-label input').check();
|
||||
await page.locator('.confirm-dialog button:has-text("Clear")').click();
|
||||
await expect(page.locator('.confirm-dialog')).not.toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const res = await request.get(`${API}/api/settings/clear_canvas_skip_confirm`);
|
||||
const body = await res.json() as { value?: string };
|
||||
return body.value;
|
||||
}).toBe('true');
|
||||
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'pref-el-2', type: 'rectangle', x: 20, y: 20, width: 80, height: 40 },
|
||||
});
|
||||
|
||||
await page.locator('button:has-text("Clear Canvas")').click();
|
||||
await expect(page.locator('.confirm-dialog')).not.toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const res = await request.get(`${API}/api/elements`);
|
||||
const body = await res.json() as { count: number };
|
||||
return body.count;
|
||||
}).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user