Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(security): response headers, rate limits, auth, CI tests, a11y (0.42.2)

Server security:
- Add CSP (default-src 'self', script-src 'self', frame-ancestors 'none'),
X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy
- Rate limit blob uploads (30/min), version creation (30/min),
ICS proxy (10/min), push subscribe (10/min)
- AI proxy: require Tailscale auth on chat completions + models endpoints
- AI proxy: validate message objects — role whitelist (system/user/assistant),
content length cap (100KB), message count cap (100)

Infrastructure:
- CI: add test job (npm test + typecheck) before Docker build
- Docker: run as non-root appuser (groupadd + useradd + chown)
- Vite: explicit sourcemap:false to prevent accidental leaks

Accessibility:
- Passphrase dialog: role=dialog, aria-modal, aria-labelledby, focus trap
- Share dialog: role=dialog, aria-modal, aria-label, focus trap,
focus restoration to trigger button on Escape

Closes #638, #639, #640, #641, #642, #643, #644, #645

+148 -8
+15
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.42.2] — 2026-04-14 11 + 12 + ### Security 13 + - Server: add CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, X-DNS-Prefetch-Control headers (#638) 14 + - Server: rate limiting added to blob uploads, version creation, ICS proxy, push subscribe (#639) 15 + - Server: AI proxy + models endpoints now require Tailscale authentication (#640) 16 + - Server: AI message validation — role whitelist, content length cap (100KB), message count cap (100) (#644) 17 + - Docker: container runs as non-root `appuser` (#642) 18 + - Vite: explicit `sourcemap: false` in production build config (#645) 19 + 20 + ### Fixed 21 + - CI: unit tests + typecheck now run before Docker build in pipeline (#641) 22 + - A11y: passphrase dialog — `role="dialog"`, `aria-modal`, `aria-labelledby`, focus trap, Escape handling (#643) 23 + - A11y: share dialog — `role="dialog"`, `aria-modal`, `aria-label`, focus trap, focus restoration on Escape (#643) 24 + 10 25 ## [0.42.1] — 2026-04-14 11 26 12 27 ### Security
+6 -1
Dockerfile
··· 31 31 COPY server.js ./ 32 32 COPY tsconfig.json tsconfig.server.json ./ 33 33 34 + # Non-root user for security 35 + RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /bin/false appuser 36 + 34 37 # Data directory for SQLite 35 - RUN mkdir -p /data 38 + RUN mkdir -p /data && chown appuser:appuser /data /app 36 39 ENV DATA_DIR=/data 37 40 ENV PORT=3000 38 41 ENV NODE_ENV=production 42 + 43 + USER appuser 39 44 40 45 EXPOSE 3000 41 46
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.42.1", 3 + "version": "0.42.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+21
server/index.ts
··· 35 35 app.use(compression()); 36 36 app.use(express.json({ limit: '1mb' })); 37 37 38 + // Security response headers 39 + app.use((_req, res, next) => { 40 + res.set('X-Content-Type-Options', 'nosniff'); 41 + res.set('X-Frame-Options', 'DENY'); 42 + res.set('Referrer-Policy', 'strict-origin-when-cross-origin'); 43 + res.set('X-DNS-Prefetch-Control', 'off'); 44 + // CSP: allow self + inline styles (TipTap/Chart.js need them) + blob: for image previews 45 + res.set('Content-Security-Policy', [ 46 + "default-src 'self'", 47 + "script-src 'self'", 48 + "style-src 'self' 'unsafe-inline'", 49 + "img-src 'self' blob: data:", 50 + "font-src 'self'", 51 + "connect-src 'self' wss:", 52 + "frame-ancestors 'none'", 53 + "base-uri 'self'", 54 + "form-action 'self'", 55 + ].join('; ')); 56 + next(); 57 + }); 58 + 38 59 // Tailscale identity middleware — reads headers injected by Tailscale Serve HTTP proxy. 39 60 // When accessed directly (localhost, Electron), headers are absent → anonymous access. 40 61 // Tailscale Serve strips these headers from incoming requests before injecting its own,
+10 -2
server/routes/ai.ts
··· 15 15 setInterval(() => aiRateLimiter.cleanup(), 60000).unref(); 16 16 17 17 router.post('/api/ai/chat/completions', async (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 18 - const rlKey = `ai:${req.tsUser?.login || 'anon'}`; 18 + if (!req.tsUser) { 19 + res.status(403).json({ error: 'Authentication required for AI features' }); 20 + return; 21 + } 22 + const rlKey = `ai:${req.tsUser.login}`; 19 23 if (!aiRateLimiter.check(rlKey, 30, 60000)) { 20 24 res.status(429).json({ error: 'AI rate limit exceeded, please slow down' }); 21 25 return; ··· 66 70 } 67 71 }); 68 72 69 - router.get('/api/ai/models', async (_req: Request, res: Response) => { 73 + router.get('/api/ai/models', async (_req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 74 + if (!_req.tsUser) { 75 + res.status(403).json({ error: 'Authentication required' }); 76 + return; 77 + } 70 78 try { 71 79 const upstream = await fetch(`${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/models`); 72 80 if (!upstream.ok) {
+9 -1
server/routes/blobs.ts
··· 6 6 import express from 'express'; 7 7 import { randomUUID } from 'crypto'; 8 8 import { stmts } from '../db.js'; 9 - import { isValidMimeType } from '../validation.js'; 9 + import { isValidMimeType, RateLimiter } from '../validation.js'; 10 10 import type { TailscaleUser, DocumentListRow } from '../types.js'; 11 11 12 12 const router = Router(); 13 + 14 + const rateLimiter = new RateLimiter(); 15 + setInterval(() => rateLimiter.cleanup(), 60000).unref(); 13 16 14 17 const BLOB_MAX_SIZE = 10 * 1024 * 1024; // 10MB per blob 15 18 16 19 router.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer> & { tsUser?: TailscaleUser | null }, res: Response) => { 20 + const rlKey = `blob:${req.tsUser?.login || req.ip || 'anon'}`; 21 + if (!rateLimiter.check(rlKey, 30, 60000)) { 22 + res.status(429).json({ error: 'Upload rate limit exceeded' }); 23 + return; 24 + } 17 25 const docIdHeader = req.headers['x-document-id']; 18 26 const docId = typeof docIdHeader === 'string' ? docIdHeader : undefined; 19 27 const fileNameHeader = req.headers['x-file-name'];
+14
server/routes/notifications.ts
··· 5 5 import { Router, type Request, type Response } from 'express'; 6 6 import webpush from 'web-push'; 7 7 import { db } from '../db.js'; 8 + import { RateLimiter } from '../validation.js'; 8 9 import type { TailscaleUser } from '../types.js'; 9 10 10 11 const router = Router(); 12 + 13 + const rateLimiter = new RateLimiter(); 14 + setInterval(() => rateLimiter.cleanup(), 60000).unref(); 11 15 12 16 // --- VAPID key management --- 13 17 ··· 97 101 router.post('/api/push/subscribe', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 98 102 if (!req.tsUser) { 99 103 res.status(403).json({ error: 'Authentication required' }); 104 + return; 105 + } 106 + const rlKey = `push:${req.tsUser.login}`; 107 + if (!rateLimiter.check(rlKey, 10, 60000)) { 108 + res.status(429).json({ error: 'Rate limit exceeded' }); 100 109 return; 101 110 } 102 111 ··· 247 256 router.get('/api/ics-proxy', async (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 248 257 if (!req.tsUser) { 249 258 res.status(403).json({ error: 'Authentication required' }); 259 + return; 260 + } 261 + const icsRlKey = `ics:${req.tsUser.login}`; 262 + if (!rateLimiter.check(icsRlKey, 10, 60000)) { 263 + res.status(429).json({ error: 'ICS proxy rate limit exceeded' }); 250 264 return; 251 265 } 252 266
+9 -1
server/routes/versions.ts
··· 6 6 import express from 'express'; 7 7 import { randomUUID } from 'crypto'; 8 8 import { db, stmts, MAX_VERSIONS_PER_DOC } from '../db.js'; 9 - import { filterMetadata } from '../validation.js'; 9 + import { filterMetadata, RateLimiter } from '../validation.js'; 10 10 import type { TailscaleUser, DocumentListRow, VersionRow, VersionSnapshotRow, VersionCountRow } from '../types.js'; 11 11 12 12 const router = Router(); 13 + 14 + const rateLimiter = new RateLimiter(); 15 + setInterval(() => rateLimiter.cleanup(), 60000).unref(); 13 16 14 17 router.get('/api/documents/:id/versions', (req: Request<{ id: string }>, res: Response) => { 15 18 const versions = stmts.getVersions.all(req.params.id) as VersionRow[]; ··· 20 23 }); 21 24 22 25 router.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 26 + const rlKey = `version:${req.tsUser?.login || req.ip || 'anon'}`; 27 + if (!rateLimiter.check(rlKey, 30, 60000)) { 28 + res.status(429).json({ error: 'Version creation rate limit exceeded' }); 29 + return; 30 + } 23 31 const docId = req.params.id; 24 32 25 33 // Owner check: allow owner, anonymous docs, and shared-edit docs
+21
server/validation.ts
··· 76 76 /** AI proxy request field whitelist */ 77 77 const AI_ALLOWED_KEYS = ['model', 'messages', 'temperature', 'max_tokens', 'stream', 'top_p', 'stop', 'presence_penalty', 'frequency_penalty'] as const; 78 78 79 + const AI_ALLOWED_ROLES = new Set(['system', 'user', 'assistant']); 80 + const AI_MAX_MESSAGE_LENGTH = 100_000; // 100KB per message 81 + const AI_MAX_MESSAGES = 100; 82 + 79 83 export function sanitizeAiRequest(body: unknown): Record<string, unknown> | null { 80 84 if (!body || typeof body !== 'object' || Array.isArray(body)) return null; 81 85 const sanitized: Record<string, unknown> = {}; ··· 85 89 } 86 90 } 87 91 if (!sanitized.messages || !Array.isArray(sanitized.messages)) return null; 92 + 93 + // Validate and sanitize each message object 94 + const messages = sanitized.messages as unknown[]; 95 + if (messages.length > AI_MAX_MESSAGES) return null; 96 + 97 + const cleanMessages: Array<{ role: string; content: string }> = []; 98 + for (const msg of messages) { 99 + if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return null; 100 + const m = msg as Record<string, unknown>; 101 + const role = typeof m.role === 'string' ? m.role : ''; 102 + const content = typeof m.content === 'string' ? m.content : ''; 103 + if (!AI_ALLOWED_ROLES.has(role)) return null; 104 + if (content.length > AI_MAX_MESSAGE_LENGTH) return null; 105 + cleanMessages.push({ role, content }); 106 + } 107 + sanitized.messages = cleanMessages; 108 + 88 109 return sanitized; 89 110 }
+19 -1
src/lib/key-passphrase.ts
··· 154 154 `; 155 155 156 156 const dialog = document.createElement('div'); 157 + dialog.setAttribute('role', 'dialog'); 158 + dialog.setAttribute('aria-modal', 'true'); 159 + dialog.setAttribute('aria-labelledby', 'passphrase-dialog-title'); 157 160 dialog.style.cssText = ` 158 161 background: var(--color-surface, #fff); color: var(--color-text, #222); 159 162 border-radius: var(--radius-lg, 10px); padding: 2rem; ··· 168 171 : 'Enter your passphrase to decrypt your document keys.'; 169 172 170 173 dialog.innerHTML = ` 171 - <h3 style="margin: 0 0 0.5rem; font-family: var(--font-display, serif);">${title}</h3> 174 + <h3 id="passphrase-dialog-title" style="margin: 0 0 0.5rem; font-family: var(--font-display, serif);">${title}</h3> 172 175 <p style="margin: 0 0 1rem; font-size: 0.85rem; color: var(--color-text-muted, #666);">${description}</p> 173 176 <input type="password" id="passphrase-input" placeholder="Passphrase" autocomplete="off" 174 177 style="width: 100%; padding: 0.5rem; border: 1px solid var(--color-border, #ccc); ··· 227 230 cancelBtn.addEventListener('click', () => { cleanup(); resolve(null); }); 228 231 input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); 229 232 confirm?.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); 233 + 234 + // Focus trap + Escape to close 235 + dialog.addEventListener('keydown', (e) => { 236 + if (e.key === 'Escape') { cleanup(); resolve(null); return; } 237 + if (e.key !== 'Tab') return; 238 + const focusable = dialog.querySelectorAll<HTMLElement>('input, button, [tabindex]:not([tabindex="-1"])'); 239 + if (focusable.length === 0) return; 240 + const first = focusable[0]!; 241 + const last = focusable[focusable.length - 1]!; 242 + if (e.shiftKey && document.activeElement === first) { 243 + e.preventDefault(); last.focus(); 244 + } else if (!e.shiftKey && document.activeElement === last) { 245 + e.preventDefault(); first.focus(); 246 + } 247 + }); 230 248 }); 231 249 }
+22 -1
src/lib/share-dialog.ts
··· 96 96 if (shareLinkInput) shareLinkInput.value = url; 97 97 } 98 98 99 + // Add ARIA attributes for accessibility 100 + shareDialog.setAttribute('role', 'dialog'); 101 + shareDialog.setAttribute('aria-modal', 'true'); 102 + shareDialog.setAttribute('aria-label', 'Share document'); 103 + 99 104 shareBtn.addEventListener('click', () => { 100 105 shareDialog.style.display = ''; 101 106 updateShareLink(); 107 + // Move focus into dialog 108 + const firstFocusable = shareDialog.querySelector<HTMLElement>('input, select, button'); 109 + if (firstFocusable) firstFocusable.focus(); 102 110 103 111 // Load current share settings from server 104 112 fetch(`/api/documents/${docId}`) ··· 141 149 } 142 150 }); 143 151 144 - // Escape to close 152 + // Escape to close + focus trap 145 153 shareDialog.addEventListener('keydown', (e: KeyboardEvent) => { 146 154 if (e.key === 'Escape') { 147 155 shareDialog.style.display = 'none'; 156 + shareBtn.focus(); // restore focus 157 + return; 158 + } 159 + if (e.key === 'Tab') { 160 + const focusable = shareDialog.querySelectorAll<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])'); 161 + if (focusable.length === 0) return; 162 + const first = focusable[0]!; 163 + const last = focusable[focusable.length - 1]!; 164 + if (e.shiftKey && document.activeElement === first) { 165 + e.preventDefault(); last.focus(); 166 + } else if (!e.shiftKey && document.activeElement === last) { 167 + e.preventDefault(); first.focus(); 168 + } 148 169 } 149 170 }); 150 171
+1
vite.config.ts
··· 40 40 build: { 41 41 outDir: '../dist', 42 42 emptyOutDir: true, 43 + sourcemap: false, 43 44 target: 'esnext', 44 45 rollupOptions: { 45 46 input: {