detached 4065baa

fix(security): harden application with CSP, security headers, input validation, and sensitive data redaction

commit
4065baab5dcd9547169013f5f49d18fd2da38e57
author
bnema · · 2025-12-15 22:04 UTC
parent
4c4546a
signature
GPG Unverified2B82207D370127A0 The key is unknown
6 files · +186 -79
M src/hooks.server.ts +20 -4
@@ -9,6 +9,21 @@ import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/sessio
99
1010 const log = createLogger('hooks-server');
1111
12+// Add security headers to response
13+function addSecurityHeaders(response: Response): Response {
14+ const headers = new Headers(response.headers);
15+ headers.set('X-Frame-Options', 'DENY');
16+ headers.set('X-Content-Type-Options', 'nosniff');
17+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
18+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
19+
20+ return new Response(response.body, {
21+ status: response.status,
22+ statusText: response.statusText,
23+ headers
24+ });
25+}
26+
1227 // Set all users to offline on server start
1328 async function setAllUsersOffline() {
1429 try {
@@ -63,7 +78,7 @@ export const handle: Handle = async ({ event, resolve }) => {
6378 if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
6479 log.debug('Public request accessed', { path: event.url.pathname });
6580 const response = await resolve(event);
66- return response;
81+ return addSecurityHeaders(response);
6782 }
6883
6984 // Public routes that don't require authentication
@@ -80,12 +95,13 @@ export const handle: Handle = async ({ event, resolve }) => {
8095 if (publicRoutes.includes(event.url.pathname)) {
8196 log.debug('Public route accessed', { path: event.url.pathname });
8297 const response = await resolve(event);
83- return response;
98+ return addSecurityHeaders(response);
8499 }
85100
86101 // Skip rate limiting for SSE endpoint when authenticated
87102 if (event.url.pathname.startsWith('/api/sse')) {
88- return await resolve(event);
103+ const response = await resolve(event);
104+ return addSecurityHeaders(response);
89105 }
90106
91107 // Apply rate limiting for non-public endpoints
@@ -102,5 +118,5 @@ export const handle: Handle = async ({ event, resolve }) => {
102118 }
103119
104120 const response = await resolve(event);
105- return response;
121+ return addSecurityHeaders(response);
106122 };
@@ -9,6 +9,21 @@ import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/sessio
9 9
10 const log = createLogger('hooks-server');10 const log = createLogger('hooks-server');
11 11
12+// Add security headers to response
13+function addSecurityHeaders(response: Response): Response {
14+ const headers = new Headers(response.headers);
15+ headers.set('X-Frame-Options', 'DENY');
16+ headers.set('X-Content-Type-Options', 'nosniff');
17+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
18+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
19+
20+ return new Response(response.body, {
21+ status: response.status,
22+ statusText: response.statusText,
23+ headers
24+ });
25+}
26+
12 // Set all users to offline on server start27 // Set all users to offline on server start
13 async function setAllUsersOffline() {28 async function setAllUsersOffline() {
14 try {29 try {
@@ -63,7 +78,7 @@ export const handle: Handle = async ({ event, resolve }) => {
63 if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {78 if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
64 log.debug('Public request accessed', { path: event.url.pathname });79 log.debug('Public request accessed', { path: event.url.pathname });
65 const response = await resolve(event);80 const response = await resolve(event);
66- return response;81+ return addSecurityHeaders(response);
67 }82 }
68 83
69 // Public routes that don't require authentication84 // Public routes that don't require authentication
@@ -80,12 +95,13 @@ export const handle: Handle = async ({ event, resolve }) => {
80 if (publicRoutes.includes(event.url.pathname)) {95 if (publicRoutes.includes(event.url.pathname)) {
81 log.debug('Public route accessed', { path: event.url.pathname });96 log.debug('Public route accessed', { path: event.url.pathname });
82 const response = await resolve(event);97 const response = await resolve(event);
83- return response;98+ return addSecurityHeaders(response);
84 }99 }
85 100
86 // Skip rate limiting for SSE endpoint when authenticated101 // Skip rate limiting for SSE endpoint when authenticated
87 if (event.url.pathname.startsWith('/api/sse')) {102 if (event.url.pathname.startsWith('/api/sse')) {
88- return await resolve(event);103+ const response = await resolve(event);
104+ return addSecurityHeaders(response);
89 }105 }
90 106
91 // Apply rate limiting for non-public endpoints107 // Apply rate limiting for non-public endpoints
@@ -102,5 +118,5 @@ export const handle: Handle = async ({ event, resolve }) => {
102 }118 }
103 119
104 const response = await resolve(event);120 const response = await resolve(event);
105- return response;121+ return addSecurityHeaders(response);
106 };122 };
M src/lib/api/rate-limiter.ts +40 -35
@@ -149,17 +149,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
149149 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
150150
151151 if (limited) {
152- // Create debug headers
153- const debugHeaders = {
154- 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
155- 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
156- 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
157- 'X-Debug-Endpoint-Type': endpointType,
158- 'X-Debug-Rate-Limit-Points': isAuthenticated
159- ? RATE_LIMITS.sse.authenticated.points.toString()
160- : RATE_LIMITS.sse.unauthenticated.points.toString()
161- };
162-
163152 // Construct a user-friendly error message
164153 let errorMessage = 'Too many requests. ';
165154 if (endpointType === 'sse') {
@@ -172,31 +161,47 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
172161 errorMessage += 'Please try again later.';
173162 }
174163
175- return new Response(
176- JSON.stringify({
177- error: errorMessage,
178- retryAfter: Math.ceil((retryAfter || 0) / 1000),
179- endpointType,
164+ // Build response body - only include debug info in development
165+ const responseBody: Record<string, unknown> = {
166+ error: errorMessage,
167+ retryAfter: Math.ceil((retryAfter || 0) / 1000)
168+ };
169+
170+ // Only include potentially sensitive info in dev mode
171+ if (isDev) {
172+ responseBody.endpointType = endpointType;
173+ responseBody.isAuthenticated = isAuthenticated;
174+ responseBody.debug = {
180175 isAuthenticated,
181- debug: {
182- isAuthenticated,
183- hasUser: Boolean(event.locals.user),
184- hasSession: Boolean(event.locals.session),
185- endpointType,
186- rateLimitPoints: isAuthenticated
187- ? RATE_LIMITS.sse.authenticated.points
188- : RATE_LIMITS.sse.unauthenticated.points
189- }
190- }),
191- {
192- status: 429,
193- headers: {
194- 'Content-Type': 'application/json',
195- 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),
196- ...debugHeaders
197- }
198- }
199- );
176+ hasUser: Boolean(event.locals.user),
177+ hasSession: Boolean(event.locals.session),
178+ endpointType,
179+ rateLimitPoints: isAuthenticated
180+ ? RATE_LIMITS.sse.authenticated.points
181+ : RATE_LIMITS.sse.unauthenticated.points
182+ };
183+ }
184+
185+ // Build headers - only include debug headers in development
186+ const headers: Record<string, string> = {
187+ 'Content-Type': 'application/json',
188+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
189+ };
190+
191+ if (isDev) {
192+ headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString();
193+ headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
194+ headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
195+ headers['X-Debug-Endpoint-Type'] = endpointType;
196+ headers['X-Debug-Rate-Limit-Points'] = isAuthenticated
197+ ? RATE_LIMITS.sse.authenticated.points.toString()
198+ : RATE_LIMITS.sse.unauthenticated.points.toString();
199+ }
200+
201+ return new Response(JSON.stringify(responseBody), {
202+ status: 429,
203+ headers
204+ });
200205 }
201206
202207 return null;
@@ -149,17 +149,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
149 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);149 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
150 150
151 if (limited) {151 if (limited) {
152- // Create debug headers
153- const debugHeaders = {
154- 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
155- 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
156- 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
157- 'X-Debug-Endpoint-Type': endpointType,
158- 'X-Debug-Rate-Limit-Points': isAuthenticated
159- ? RATE_LIMITS.sse.authenticated.points.toString()
160- : RATE_LIMITS.sse.unauthenticated.points.toString()
161- };
162-
163 // Construct a user-friendly error message152 // Construct a user-friendly error message
164 let errorMessage = 'Too many requests. ';153 let errorMessage = 'Too many requests. ';
165 if (endpointType === 'sse') {154 if (endpointType === 'sse') {
@@ -172,31 +161,47 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
172 errorMessage += 'Please try again later.';161 errorMessage += 'Please try again later.';
173 }162 }
174 163
175- return new Response(164+ // Build response body - only include debug info in development
176- JSON.stringify({165+ const responseBody: Record<string, unknown> = {
177- error: errorMessage,166+ error: errorMessage,
178- retryAfter: Math.ceil((retryAfter || 0) / 1000),167+ retryAfter: Math.ceil((retryAfter || 0) / 1000)
179- endpointType,168+ };
169+
170+ // Only include potentially sensitive info in dev mode
171+ if (isDev) {
172+ responseBody.endpointType = endpointType;
173+ responseBody.isAuthenticated = isAuthenticated;
174+ responseBody.debug = {
180 isAuthenticated,175 isAuthenticated,
181- debug: {176+ hasUser: Boolean(event.locals.user),
182- isAuthenticated,177+ hasSession: Boolean(event.locals.session),
183- hasUser: Boolean(event.locals.user),178+ endpointType,
184- hasSession: Boolean(event.locals.session),179+ rateLimitPoints: isAuthenticated
185- endpointType,180+ ? RATE_LIMITS.sse.authenticated.points
186- rateLimitPoints: isAuthenticated181+ : RATE_LIMITS.sse.unauthenticated.points
187- ? RATE_LIMITS.sse.authenticated.points182+ };
188- : RATE_LIMITS.sse.unauthenticated.points183+ }
189- }184+
190- }),185+ // Build headers - only include debug headers in development
191- {186+ const headers: Record<string, string> = {
192- status: 429,187+ 'Content-Type': 'application/json',
193- headers: {188+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
194- 'Content-Type': 'application/json',189+ };
195- 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),190+
196- ...debugHeaders191+ if (isDev) {
197- }192+ headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString();
198- }193+ headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
199- );194+ headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
195+ headers['X-Debug-Endpoint-Type'] = endpointType;
196+ headers['X-Debug-Rate-Limit-Points'] = isAuthenticated
197+ ? RATE_LIMITS.sse.authenticated.points.toString()
198+ : RATE_LIMITS.sse.unauthenticated.points.toString();
199+ }
200+
201+ return new Response(JSON.stringify(responseBody), {
202+ status: 429,
203+ headers
204+ });
200 }205 }
201 206
202 return null;207 return null;
M src/lib/api/session.cookie.ts +12 -4
@@ -5,6 +5,13 @@ const log = createLogger('session-cookie');
55
66 // Helper function to determine cookie domain
77 function getCookieDomain(): string | undefined {
8+ // Allow explicit override via environment variable
9+ const envDomain = process.env.COOKIE_DOMAIN;
10+ if (envDomain) {
11+ log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain });
12+ return envDomain;
13+ }
14+
815 if (process.env.NODE_ENV === 'production') {
916 // Check for Cloudflare Pages domain
1017 if (process.env.CF_PAGES_URL) {
@@ -14,11 +21,12 @@ function getCookieDomain(): string | undefined {
1421 if (process.env.CF_PAGES_BRANCH !== 'main') {
1522 return undefined;
1623 }
17- // For production, use the main domain
18- return '.pdraim.pages.dev';
24+ // For production on Cloudflare Pages, use host-only cookie (safest default)
25+ // Set COOKIE_DOMAIN explicitly if cross-subdomain cookies are needed
26+ return undefined;
1927 }
20- // Fallback for custom domain
21- return '.pdraim.org';
28+ // Production without explicit domain - use host-only cookie (safest default)
29+ return undefined;
2230 }
2331 return undefined;
2432 }
@@ -5,6 +5,13 @@ const log = createLogger('session-cookie');
5 5
6 // Helper function to determine cookie domain6 // Helper function to determine cookie domain
7 function getCookieDomain(): string | undefined {7 function getCookieDomain(): string | undefined {
8+ // Allow explicit override via environment variable
9+ const envDomain = process.env.COOKIE_DOMAIN;
10+ if (envDomain) {
11+ log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain });
12+ return envDomain;
13+ }
14+
8 if (process.env.NODE_ENV === 'production') {15 if (process.env.NODE_ENV === 'production') {
9 // Check for Cloudflare Pages domain16 // Check for Cloudflare Pages domain
10 if (process.env.CF_PAGES_URL) {17 if (process.env.CF_PAGES_URL) {
@@ -14,11 +21,12 @@ function getCookieDomain(): string | undefined {
14 if (process.env.CF_PAGES_BRANCH !== 'main') {21 if (process.env.CF_PAGES_BRANCH !== 'main') {
15 return undefined;22 return undefined;
16 }23 }
17- // For production, use the main domain24+ // For production on Cloudflare Pages, use host-only cookie (safest default)
18- return '.pdraim.pages.dev';25+ // Set COOKIE_DOMAIN explicitly if cross-subdomain cookies are needed
26+ return undefined;
19 }27 }
20- // Fallback for custom domain28+ // Production without explicit domain - use host-only cookie (safest default)
21- return '.pdraim.org';29+ return undefined;
22 }30 }
23 return undefined;31 return undefined;
24 }32 }
M src/lib/utils/password.ts +0 -5
@@ -32,7 +32,6 @@ function hexToBuffer(hex: string): Uint8Array {
3232 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {
3333 const enc = new TextEncoder();
3434 const keyMaterial = enc.encode(password);
35- console.debug('$state: Key material for password derivation', keyMaterial);
3635 const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [
3736 'deriveBits'
3837 ]);
@@ -46,7 +45,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
4645 key,
4746 512
4847 );
49- console.debug('$state: Derived bits', derivedBits);
5048 return bufferToHex(derivedBits);
5149 }
5250
@@ -57,7 +55,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
5755 */
5856 export async function hashPassword(password: string): Promise<string> {
5957 const salt = crypto.getRandomValues(new Uint8Array(16));
60- console.debug('$state: Generated salt', salt);
6158 const saltHex = bufferToHex(salt.buffer);
6259 const hash = await derivePasswordHash(password, salt);
6360 return `${saltHex}:${hash}`;
@@ -72,8 +69,6 @@ export async function hashPassword(password: string): Promise<string> {
7269 export async function verifyPassword(password: string, hash: string): Promise<boolean> {
7370 const [saltHex, hashValue] = hash.split(':');
7471 const salt = hexToBuffer(saltHex);
75- console.debug('$state: Salt extracted from stored hash', salt);
7672 const derivedHash = await derivePasswordHash(password, salt);
77- console.debug('$state: Derived hash for verification', derivedHash);
7873 return derivedHash === hashValue;
7974 }
@@ -32,7 +32,6 @@ function hexToBuffer(hex: string): Uint8Array {
32 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {32 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {
33 const enc = new TextEncoder();33 const enc = new TextEncoder();
34 const keyMaterial = enc.encode(password);34 const keyMaterial = enc.encode(password);
35- console.debug('$state: Key material for password derivation', keyMaterial);
36 const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [35 const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [
37 'deriveBits'36 'deriveBits'
38 ]);37 ]);
@@ -46,7 +45,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
46 key,45 key,
47 51246 512
48 );47 );
49- console.debug('$state: Derived bits', derivedBits);
50 return bufferToHex(derivedBits);48 return bufferToHex(derivedBits);
51 }49 }
52 50
@@ -57,7 +55,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
57 */55 */
58 export async function hashPassword(password: string): Promise<string> {56 export async function hashPassword(password: string): Promise<string> {
59 const salt = crypto.getRandomValues(new Uint8Array(16));57 const salt = crypto.getRandomValues(new Uint8Array(16));
60- console.debug('$state: Generated salt', salt);
61 const saltHex = bufferToHex(salt.buffer);58 const saltHex = bufferToHex(salt.buffer);
62 const hash = await derivePasswordHash(password, salt);59 const hash = await derivePasswordHash(password, salt);
63 return `${saltHex}:${hash}`;60 return `${saltHex}:${hash}`;
@@ -72,8 +69,6 @@ export async function hashPassword(password: string): Promise<string> {
72 export async function verifyPassword(password: string, hash: string): Promise<boolean> {69 export async function verifyPassword(password: string, hash: string): Promise<boolean> {
73 const [saltHex, hashValue] = hash.split(':');70 const [saltHex, hashValue] = hash.split(':');
74 const salt = hexToBuffer(saltHex);71 const salt = hexToBuffer(saltHex);
75- console.debug('$state: Salt extracted from stored hash', salt);
76 const derivedHash = await derivePasswordHash(password, salt);72 const derivedHash = await derivePasswordHash(password, salt);
77- console.debug('$state: Derived hash for verification', derivedHash);
78 return derivedHash === hashValue;73 return derivedHash === hashValue;
79 }74 }
M src/lib/utils/text-formatter.ts +99 -30
@@ -25,8 +25,62 @@ const ALLOWED_SCHEMA = {
2525 }
2626 };
2727
28-// BBCode-style formatting patterns
29-const BB_CODE_PATTERNS: Array<{
28+// Validation functions for BBCode values
29+const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
30+const NAMED_COLORS = new Set([
31+ 'red',
32+ 'blue',
33+ 'green',
34+ 'yellow',
35+ 'orange',
36+ 'purple',
37+ 'pink',
38+ 'black',
39+ 'white',
40+ 'gray',
41+ 'grey',
42+ 'brown',
43+ 'cyan',
44+ 'magenta',
45+ 'lime',
46+ 'navy',
47+ 'teal',
48+ 'maroon'
49+]);
50+const ALLOWED_FONTS = new Set([
51+ 'arial',
52+ 'helvetica',
53+ 'verdana',
54+ 'georgia',
55+ 'times',
56+ 'courier',
57+ 'comic sans ms',
58+ 'impact',
59+ 'trebuchet ms',
60+ 'lucida console',
61+ 'monospace',
62+ 'sans-serif',
63+ 'serif'
64+]);
65+const MIN_FONT_SIZE = 8;
66+const MAX_FONT_SIZE = 24;
67+
68+function isValidColor(color: string): boolean {
69+ const lower = color.toLowerCase();
70+ return HEX_COLOR_REGEX.test(color) || NAMED_COLORS.has(lower);
71+}
72+
73+function isValidFontSize(size: string): boolean {
74+ const num = parseInt(size, 10);
75+ return !isNaN(num) && num >= MIN_FONT_SIZE && num <= MAX_FONT_SIZE;
76+}
77+
78+function isValidFont(font: string): boolean {
79+ return ALLOWED_FONTS.has(font.toLowerCase().trim());
80+}
81+
82+// BBCode-style formatting patterns (simple ones with static replacement)
83+const BB_CODE_SIMPLE_PATTERNS: Array<{
3084 pattern: RegExp;
3185 replacement: string;
3286 }> = [
@@ -40,26 +94,39 @@ const BB_CODE_PATTERNS: Array<{
4094 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
4195
4296 // Strikethrough
43- { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },
97+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }
98+];
4499
45- // Color
46- {
47- pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi,
48- replacement: '<span style="color: $1">$2</span>'
49- },
100+// BBCode patterns that require validation
101+function processBBCodeWithValidation(text: string): string {
102+ let result = text;
50103
51- // Size
52- {
53- pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi,
54- replacement: '<span style="font-size: $1px">$2</span>'
55- },
104+ // Color - validate hex or named colors
105+ result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => {
106+ if (isValidColor(color)) {
107+ return `<span style="color: ${color}">${content}</span>`;
108+ }
109+ return content; // Strip invalid color, keep content
110+ });
56111
57- // Font
58- {
59- pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi,
60- replacement: '<span style="font-family: $1">$2</span>'
61- }
62-];
112+ // Size - validate range 8-24px
113+ result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => {
114+ if (isValidFontSize(size)) {
115+ return `<span style="font-size: ${size}px">${content}</span>`;
116+ }
117+ return content; // Strip invalid size, keep content
118+ });
119+
120+ // Font - validate against allowed fonts
121+ result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => {
122+ if (isValidFont(font)) {
123+ return `<span style="font-family: ${font}">${content}</span>`;
124+ }
125+ return content; // Strip invalid font, keep content
126+ });
127+
128+ return result;
129+}
63130
64131 // Markdown-style formatting patterns
65132 const MARKDOWN_PATTERNS: Array<{
@@ -85,11 +152,14 @@ const MARKDOWN_PATTERNS: Array<{
85152 function preprocessFormatting(text: string): string {
86153 let processedText = text;
87154
88- // Apply BBCode patterns first
89- BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {
155+ // Apply simple BBCode patterns first (bold, italic, underline, strikethrough)
156+ BB_CODE_SIMPLE_PATTERNS.forEach(({ pattern, replacement }) => {
90157 processedText = processedText.replace(pattern, replacement);
91158 });
92159
160+ // Apply BBCode patterns that require validation (color, size, font)
161+ processedText = processBBCodeWithValidation(processedText);
162+
93163 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
94164 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
95165 // Only apply if not already inside HTML tags
@@ -176,11 +246,14 @@ export async function formatText(
176246 }
177247 }
178248
179-// Utility function to escape HTML
180-function escapeHtml(text: string): string {
181- const div = document.createElement('div');
182- div.textContent = text;
183- return div.innerHTML;
249+// Utility function to escape HTML (isomorphic - works in SSR and browser)
250+export function escapeHtml(text: string): string {
251+ return text
252+ .replace(/&/g, '&amp;')
253+ .replace(/</g, '&lt;')
254+ .replace(/>/g, '&gt;')
255+ .replace(/"/g, '&quot;')
256+ .replace(/'/g, '&#039;');
184257 }
185258
186259 // Generate CSS style string excluding font-family (which should be handled by classes)
@@ -223,13 +296,10 @@ export function createGradientText(
223296 className: string = 'gradient-text',
224297 style?: TextStyle
225298 ): string {
226- console.debug('createGradientText called with:', { text, colors, className, style });
227-
228299 if (colors.length < 2) {
229300 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
230301 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';
231302 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
232- console.debug('Single color result:', result);
233303 return result;
234304 }
235305
@@ -294,7 +364,6 @@ export function createGradientText(
294364 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })
295365 : '';
296366 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;
297- console.debug('Final gradient HTML:', result);
298367 return result;
299368 }
300369
@@ -25,8 +25,62 @@ const ALLOWED_SCHEMA = {
25 }25 }
26 };26 };
27 27
28-// BBCode-style formatting patterns28+// Validation functions for BBCode values
29-const BB_CODE_PATTERNS: Array<{29+const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
30+const NAMED_COLORS = new Set([
31+ 'red',
32+ 'blue',
33+ 'green',
34+ 'yellow',
35+ 'orange',
36+ 'purple',
37+ 'pink',
38+ 'black',
39+ 'white',
40+ 'gray',
41+ 'grey',
42+ 'brown',
43+ 'cyan',
44+ 'magenta',
45+ 'lime',
46+ 'navy',
47+ 'teal',
48+ 'maroon'
49+]);
50+const ALLOWED_FONTS = new Set([
51+ 'arial',
52+ 'helvetica',
53+ 'verdana',
54+ 'georgia',
55+ 'times',
56+ 'courier',
57+ 'comic sans ms',
58+ 'impact',
59+ 'trebuchet ms',
60+ 'lucida console',
61+ 'monospace',
62+ 'sans-serif',
63+ 'serif'
64+]);
65+const MIN_FONT_SIZE = 8;
66+const MAX_FONT_SIZE = 24;
67+
68+function isValidColor(color: string): boolean {
69+ const lower = color.toLowerCase();
70+ return HEX_COLOR_REGEX.test(color) || NAMED_COLORS.has(lower);
71+}
72+
73+function isValidFontSize(size: string): boolean {
74+ const num = parseInt(size, 10);
75+ return !isNaN(num) && num >= MIN_FONT_SIZE && num <= MAX_FONT_SIZE;
76+}
77+
78+function isValidFont(font: string): boolean {
79+ return ALLOWED_FONTS.has(font.toLowerCase().trim());
80+}
81+
82+// BBCode-style formatting patterns (simple ones with static replacement)
83+const BB_CODE_SIMPLE_PATTERNS: Array<{
30 pattern: RegExp;84 pattern: RegExp;
31 replacement: string;85 replacement: string;
32 }> = [86 }> = [
@@ -40,26 +94,39 @@ const BB_CODE_PATTERNS: Array<{
40 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },94 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
41 95
42 // Strikethrough96 // Strikethrough
43- { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },97+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }
98+];
44 99
45- // Color100+// BBCode patterns that require validation
46- {101+function processBBCodeWithValidation(text: string): string {
47- pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi,102+ let result = text;
48- replacement: '<span style="color: $1">$2</span>'
49- },
50 103
51- // Size104+ // Color - validate hex or named colors
52- {105+ result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => {
53- pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi,106+ if (isValidColor(color)) {
54- replacement: '<span style="font-size: $1px">$2</span>'107+ return `<span style="color: ${color}">${content}</span>`;
55- },108+ }
109+ return content; // Strip invalid color, keep content
110+ });
56 111
57- // Font112+ // Size - validate range 8-24px
58- {113+ result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => {
59- pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi,114+ if (isValidFontSize(size)) {
60- replacement: '<span style="font-family: $1">$2</span>'115+ return `<span style="font-size: ${size}px">${content}</span>`;
61- }116+ }
62-];117+ return content; // Strip invalid size, keep content
118+ });
119+
120+ // Font - validate against allowed fonts
121+ result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => {
122+ if (isValidFont(font)) {
123+ return `<span style="font-family: ${font}">${content}</span>`;
124+ }
125+ return content; // Strip invalid font, keep content
126+ });
127+
128+ return result;
129+}
63 130
64 // Markdown-style formatting patterns131 // Markdown-style formatting patterns
65 const MARKDOWN_PATTERNS: Array<{132 const MARKDOWN_PATTERNS: Array<{
@@ -85,11 +152,14 @@ const MARKDOWN_PATTERNS: Array<{
85 function preprocessFormatting(text: string): string {152 function preprocessFormatting(text: string): string {
86 let processedText = text;153 let processedText = text;
87 154
88- // Apply BBCode patterns first155+ // Apply simple BBCode patterns first (bold, italic, underline, strikethrough)
89- BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {156+ BB_CODE_SIMPLE_PATTERNS.forEach(({ pattern, replacement }) => {
90 processedText = processedText.replace(pattern, replacement);157 processedText = processedText.replace(pattern, replacement);
91 });158 });
92 159
160+ // Apply BBCode patterns that require validation (color, size, font)
161+ processedText = processBBCodeWithValidation(processedText);
162+
93 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)163 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
94 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {164 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
95 // Only apply if not already inside HTML tags165 // Only apply if not already inside HTML tags
@@ -176,11 +246,14 @@ export async function formatText(
176 }246 }
177 }247 }
178 248
179-// Utility function to escape HTML249+// Utility function to escape HTML (isomorphic - works in SSR and browser)
180-function escapeHtml(text: string): string {250+export function escapeHtml(text: string): string {
181- const div = document.createElement('div');251+ return text
182- div.textContent = text;252+ .replace(/&/g, '&amp;')
183- return div.innerHTML;253+ .replace(/</g, '&lt;')
254+ .replace(/>/g, '&gt;')
255+ .replace(/"/g, '&quot;')
256+ .replace(/'/g, '&#039;');
184 }257 }
185 258
186 // Generate CSS style string excluding font-family (which should be handled by classes)259 // Generate CSS style string excluding font-family (which should be handled by classes)
@@ -223,13 +296,10 @@ export function createGradientText(
223 className: string = 'gradient-text',296 className: string = 'gradient-text',
224 style?: TextStyle297 style?: TextStyle
225 ): string {298 ): string {
226- console.debug('createGradientText called with:', { text, colors, className, style });
227-
228 if (colors.length < 2) {299 if (colors.length < 2) {
229 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';300 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
230 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';301 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';
231 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;302 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
232- console.debug('Single color result:', result);
233 return result;303 return result;
234 }304 }
235 305
@@ -294,7 +364,6 @@ export function createGradientText(
294 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })364 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })
295 : '';365 : '';
296 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;366 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;
297- console.debug('Final gradient HTML:', result);
298 return result;367 return result;
299 }368 }
300 369
M svelte.config.js +15 -1
@@ -11,7 +11,21 @@ const config = {
1111 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
1212 // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
1313 // See https://svelte.dev/docs/kit/adapters for more information about adapters.
14- adapter: adapter()
14+ adapter: adapter(),
15+ csp: {
16+ mode: 'auto',
17+ directives: {
18+ 'default-src': ['self'],
19+ 'script-src': ['self'],
20+ 'style-src': ['self', 'unsafe-inline'],
21+ 'img-src': ['self', 'data:', 'blob:'],
22+ 'connect-src': ['self'],
23+ 'font-src': ['self'],
24+ 'frame-ancestors': ['none'],
25+ 'base-uri': ['self'],
26+ 'form-action': ['self']
27+ }
28+ }
1529 }
1630 };
1731
@@ -11,7 +11,21 @@ const config = {
11 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.11 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
12 // If your environment is not supported, or you settled on a specific environment, switch out the adapter.12 // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
13 // See https://svelte.dev/docs/kit/adapters for more information about adapters.13 // See https://svelte.dev/docs/kit/adapters for more information about adapters.
14- adapter: adapter()14+ adapter: adapter(),
15+ csp: {
16+ mode: 'auto',
17+ directives: {
18+ 'default-src': ['self'],
19+ 'script-src': ['self'],
20+ 'style-src': ['self', 'unsafe-inline'],
21+ 'img-src': ['self', 'data:', 'blob:'],
22+ 'connect-src': ['self'],
23+ 'font-src': ['self'],
24+ 'frame-ancestors': ['none'],
25+ 'base-uri': ['self'],
26+ 'form-action': ['self']
27+ }
28+ }
15 }29 }
16 };30 };
17 31

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close