diff --git a/apps/backend/supabase/migrations/0006_rls.sql b/apps/backend/supabase/migrations/0006_rls.sql index 782d3fea26..c2ccc550dd 100644 --- a/apps/backend/supabase/migrations/0006_rls.sql +++ b/apps/backend/supabase/migrations/0006_rls.sql @@ -1,3 +1,36 @@ +-- Audit logging function for security events +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_type TEXT NOT NULL, + user_id UUID, + resource_type TEXT, + resource_id UUID, + action TEXT NOT NULL, + details JSONB, + ip_address INET, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID DEFAULT auth.uid() +); + +-- Create index on audit_logs for efficient querying +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit_logs(event_type); + +-- Helper function to log security events +CREATE OR REPLACE FUNCTION log_security_event( + event_type TEXT, + action TEXT, + resource_type TEXT DEFAULT NULL, + resource_id UUID DEFAULT NULL, + details JSONB DEFAULT NULL +) RETURNS VOID AS $$ +BEGIN + INSERT INTO audit_logs (event_type, user_id, resource_type, resource_id, action, details, created_by) + VALUES (event_type, auth.uid(), resource_type, resource_id, action, details, auth.uid()); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + -- Helper function to check if user has specific roles for a project CREATE OR REPLACE FUNCTION user_has_project_access( project_id_param UUID, @@ -78,6 +111,13 @@ FOR SELECT TO authenticated USING (user_has_project_access(canvas.project_id, ARRAY['owner', 'admin'])); +DROP POLICY IF EXISTS "canvas_deny_select_policy" ON canvas; +-- Deny SELECT access to unauthenticated users +CREATE POLICY "canvas_deny_select_policy" ON canvas +FOR SELECT +TO public +USING (false); + DROP POLICY IF EXISTS "canvas_update_policy" ON canvas; -- 3. UPDATE: Allow users with 'owner' or 'admin' role in user_projects for the canvas's project CREATE POLICY "canvas_update_policy" ON canvas diff --git a/apps/web/client/src/app/api/email-capture/route.ts b/apps/web/client/src/app/api/email-capture/route.ts index bd47168b47..c21bad6421 100644 --- a/apps/web/client/src/app/api/email-capture/route.ts +++ b/apps/web/client/src/app/api/email-capture/route.ts @@ -1,18 +1,69 @@ import { env } from '@/env'; import { z } from 'zod'; + +// Simple HTML sanitization function to prevent XSS +function sanitizeHtml(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); +} + +// Rate limiter: simple in-memory store (in production, use Redis or similar) +const requestCounts = new Map(); +const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute + +function isRateLimited(clientIp: string): boolean { + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW_MS; + + if (!requestCounts.has(clientIp)) { + requestCounts.set(clientIp, []); + } + + const timestamps = requestCounts.get(clientIp)!; + // Remove old timestamps outside the window + const recentTimestamps = timestamps.filter(t => t > windowStart); + + if (recentTimestamps.length >= RATE_LIMIT_MAX_REQUESTS) { + return true; + } + + recentTimestamps.push(now); + requestCounts.set(clientIp, recentTimestamps); + return false; +} + export async function POST(request: Request) { try { + // Extract client IP for rate limiting + const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown'; + + // Check rate limit + if (isRateLimited(clientIp)) { + return new Response(JSON.stringify({ error: 'Too many requests' }), { + status: 429, + headers: { 'Content-Type': 'application/json' } + }); + } + const { name, email, utm_source, utm_medium, utm_campaign, utm_term, utm_content } = await request.json(); // Create Zod schema for validation const emailCaptureSchema = z.object({ - name: z.string().trim().min(1, 'Name is required'), - email: z.string().trim().email('Invalid email format'), - utm_source: z.string().optional(), - utm_medium: z.string().optional(), - utm_campaign: z.string().optional(), - utm_term: z.string().optional(), - utm_content: z.string().optional(), + name: z.string().trim().min(1, 'Name is required').max(255, 'Name is too long'), + email: z.string().trim().email('Invalid email format').max(255, 'Email is too long'), + utm_source: z.string().optional().max(255, 'utm_source is too long'), + utm_medium: z.string().optional().max(255, 'utm_medium is too long'), + utm_campaign: z.string().optional().max(255, 'utm_campaign is too long'), + utm_term: z.string().optional().max(255, 'utm_term is too long'), + utm_content: z.string().optional().max(255, 'utm_content is too long'), }); // Validate input data with Zod @@ -36,6 +87,17 @@ export async function POST(request: Request) { const validatedData = validationResult.data; + // Sanitize string fields to prevent XSS + const sanitizedData = { + name: sanitizeHtml(validatedData.name), + email: validatedData.email, // Email is already validated and safe + utm_source: validatedData.utm_source ? sanitizeHtml(validatedData.utm_source) : undefined, + utm_medium: validatedData.utm_medium ? sanitizeHtml(validatedData.utm_medium) : undefined, + utm_campaign: validatedData.utm_campaign ? sanitizeHtml(validatedData.utm_campaign) : undefined, + utm_term: validatedData.utm_term ? sanitizeHtml(validatedData.utm_term) : undefined, + utm_content: validatedData.utm_content ? sanitizeHtml(validatedData.utm_content) : undefined, + }; + const headerName = env.N8N_LANDING_FORM_HEADER_NAME; const headerValue = env.N8N_LANDING_FORM_HEADER_VALUE; const landingFormUrl = env.N8N_LANDING_FORM_URL; @@ -49,14 +111,14 @@ export async function POST(request: Request) { } const url = new URL(landingFormUrl); - url.searchParams.append('name', validatedData.name); - url.searchParams.append('email', validatedData.email); - - if (validatedData.utm_source) url.searchParams.append('utm_source', validatedData.utm_source); - if (validatedData.utm_medium) url.searchParams.append('utm_medium', validatedData.utm_medium); - if (validatedData.utm_campaign) url.searchParams.append('utm_campaign', validatedData.utm_campaign); - if (validatedData.utm_term) url.searchParams.append('utm_term', validatedData.utm_term); - if (validatedData.utm_content) url.searchParams.append('utm_content', validatedData.utm_content); + url.searchParams.append('name', sanitizedData.name); + url.searchParams.append('email', sanitizedData.email); + + if (sanitizedData.utm_source) url.searchParams.append('utm_source', sanitizedData.utm_source); + if (sanitizedData.utm_medium) url.searchParams.append('utm_medium', sanitizedData.utm_medium); + if (sanitizedData.utm_campaign) url.searchParams.append('utm_campaign', sanitizedData.utm_campaign); + if (sanitizedData.utm_term) url.searchParams.append('utm_term', sanitizedData.utm_term); + if (sanitizedData.utm_content) url.searchParams.append('utm_content', sanitizedData.utm_content); // Build auth headers: use custom header if provided const authHeaders: Record = {}; diff --git a/apps/web/client/src/server/api/routers/domain/adapters/freestyle.ts b/apps/web/client/src/server/api/routers/domain/adapters/freestyle.ts index 17cf11a0b4..6afffba588 100644 --- a/apps/web/client/src/server/api/routers/domain/adapters/freestyle.ts +++ b/apps/web/client/src/server/api/routers/domain/adapters/freestyle.ts @@ -6,8 +6,60 @@ import type { DeploymentResponse } from '@onlook/models'; +// File upload validation constants +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file +const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500MB total +const MAX_FILES = 1000; +const ALLOWED_FILE_TYPES = [ + 'text/html', + 'text/css', + 'application/javascript', + 'text/javascript', + 'application/json', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/svg+xml', + 'application/pdf', +]; + export class FreestyleAdapter implements HostingProviderAdapter { + private validateFileUploads(files: Record): void { + // Validate number of files + const fileCount = Object.keys(files).length; + if (fileCount === 0) { + throw new Error('No files provided'); + } + if (fileCount > MAX_FILES) { + throw new Error(`Too many files. Maximum allowed: ${MAX_FILES}`); + } + + // Validate individual files and calculate total size + let totalSize = 0; + for (const [filename, file] of Object.entries(files)) { + // Validate file size + if (file.size > MAX_FILE_SIZE) { + throw new Error(`File "${filename}" exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`); + } + + // Validate file type + if (file.type && !ALLOWED_FILE_TYPES.includes(file.type)) { + throw new Error(`File type "${file.type}" for "${filename}" is not allowed`); + } + + totalSize += file.size; + } + + // Validate total size + if (totalSize > MAX_TOTAL_SIZE) { + throw new Error(`Total file size exceeds maximum of ${MAX_TOTAL_SIZE / 1024 / 1024}MB`); + } + } + async deploy(request: DeploymentRequest): Promise { + // Validate file uploads before processing + this.validateFileUploads(request.files); + const sdk = initializeFreestyleSdk(); const res = await sdk.deployWeb(