diff --git a/package-lock.json b/package-lock.json index e003029e..e80739de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", + "rehype-sanitize": "^6.0.0", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", @@ -5703,6 +5704,21 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -7751,6 +7767,20 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", diff --git a/package.json b/package.json index 340b6b5f..717f10b8 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", + "rehype-sanitize": "^6.0.0", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/components/blog/MarkdownRenderer.tsx b/src/components/blog/MarkdownRenderer.tsx index 622d54d6..4494de03 100644 --- a/src/components/blog/MarkdownRenderer.tsx +++ b/src/components/blog/MarkdownRenderer.tsx @@ -1,4 +1,5 @@ import ReactMarkdown from 'react-markdown'; +import rehypeSanitize from 'rehype-sanitize'; import { cn } from '@/lib/utils'; interface MarkdownRendererProps { @@ -25,7 +26,10 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) className )} > - + {content} diff --git a/supabase/functions/_shared/errorSanitizer.ts b/supabase/functions/_shared/errorSanitizer.ts new file mode 100644 index 00000000..e780db5f --- /dev/null +++ b/supabase/functions/_shared/errorSanitizer.ts @@ -0,0 +1,103 @@ +/** + * Error sanitization utility to prevent information disclosure + * Prevents exposing database schema, RLS policies, and internal implementation details + */ + +interface SanitizedError { + error: string; + code?: string; +} + +const ERROR_PATTERNS: Record = { + 'duplicate key': 'A record with this information already exists', + 'foreign key': 'Invalid reference to related data', + 'violates check': 'The provided data does not meet requirements', + 'not-null': 'Required field is missing', + 'violates row-level security': 'Access denied', + 'permission denied': 'Access denied', + 'authentication': 'Authentication failed', + 'jwt': 'Authentication failed', + 'unique constraint': 'This value is already in use', + 'invalid input syntax': 'Invalid data format', + 'value too long': 'Value exceeds maximum length', + 'numeric field overflow': 'Number value is too large', +}; + +/** + * Sanitizes error messages to prevent information disclosure + * Logs full error server-side for debugging + */ +export function sanitizeError(error: unknown, context?: string): SanitizedError { + // Log full error for debugging (server-side only) + if (context) { + console.error(`[${context}] Error:`, error); + } else { + console.error('Error:', error); + } + + // Handle non-Error objects + if (!error || typeof error !== 'object') { + return { + error: 'An unexpected error occurred. Please try again.', + code: 'UNKNOWN_ERROR' + }; + } + + const errorMessage = (error as Error).message || ''; + const errorMessageLower = errorMessage.toLowerCase(); + + // Check for known patterns + for (const [pattern, safeMessage] of Object.entries(ERROR_PATTERNS)) { + if (errorMessageLower.includes(pattern.toLowerCase())) { + return { + error: safeMessage, + code: pattern.toUpperCase().replace(/\s+/g, '_') + }; + } + } + + // Rate limiting errors + if (errorMessageLower.includes('rate limit') || errorMessageLower.includes('too many')) { + return { + error: 'Too many requests. Please try again later.', + code: 'RATE_LIMITED' + }; + } + + // Network/timeout errors + if (errorMessageLower.includes('timeout') || errorMessageLower.includes('network')) { + return { + error: 'Connection error. Please check your internet connection and try again.', + code: 'NETWORK_ERROR' + }; + } + + // Default safe error + return { + error: 'An error occurred while processing your request. Please try again or contact support if the problem persists.', + code: 'INTERNAL_ERROR' + }; +} + +/** + * Creates a sanitized error response for edge functions + */ +export function createErrorResponse( + error: unknown, + status: number = 500, + corsHeaders: Record = {}, + context?: string +): Response { + const sanitized = sanitizeError(error, context); + + return new Response( + JSON.stringify(sanitized), + { + status, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json' + } + } + ); +} diff --git a/supabase/functions/export-user-data/index.ts b/supabase/functions/export-user-data/index.ts index a91c46c7..83b33b7d 100644 --- a/supabase/functions/export-user-data/index.ts +++ b/supabase/functions/export-user-data/index.ts @@ -1,5 +1,6 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; +import { sanitizeError } from '../_shared/errorSanitizer.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -278,9 +279,10 @@ serve(async (req) => { } catch (error) { console.error('[Export] Error:', error); + const sanitized = sanitizeError(error, 'export-user-data'); return new Response( JSON.stringify({ - error: error instanceof Error ? error.message : 'An unexpected error occurred', + ...sanitized, success: false }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 265025a6..333a56bb 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -1,6 +1,7 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { validateEntityData, validateEntityDataStrict } from "./validation.ts"; +import { createErrorResponse } from "../_shared/errorSanitizer.ts"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -437,9 +438,11 @@ serve(async (req) => { ); } catch (error) { console.error('Error in process-selective-approval:', error); - return new Response( - JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + return createErrorResponse( + error, + 500, + corsHeaders, + 'process-selective-approval' ); } }); diff --git a/supabase/migrations/20251016195944_43e0e937-c5f3-4991-93d9-467caf0337b3.sql b/supabase/migrations/20251016195944_43e0e937-c5f3-4991-93d9-467caf0337b3.sql new file mode 100644 index 00000000..09a5f7e0 --- /dev/null +++ b/supabase/migrations/20251016195944_43e0e937-c5f3-4991-93d9-467caf0337b3.sql @@ -0,0 +1,44 @@ +-- Fix database functions missing SET search_path protection +-- This prevents schema poisoning attacks + +-- Fix has_aal2 function +CREATE OR REPLACE FUNCTION public.has_aal2() +RETURNS boolean +LANGUAGE sql +STABLE SECURITY DEFINER +SET search_path = public +AS $function$ + SELECT COALESCE((auth.jwt()->>'aal')::text = 'aal2', false); +$function$; + +-- Fix generate_deletion_confirmation_code function +CREATE OR REPLACE FUNCTION public.generate_deletion_confirmation_code() +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $function$ +DECLARE + code TEXT; +BEGIN + code := LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0'); + RETURN code; +END; +$function$; + +-- Fix hash_ip_address function +CREATE OR REPLACE FUNCTION public.hash_ip_address(ip_text text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +SECURITY DEFINER +SET search_path = public +AS $function$ +BEGIN + -- Use SHA256 hash with salt + RETURN encode( + digest(ip_text || 'thrillwiki_ip_salt_2025', 'sha256'), + 'hex' + ); +END; +$function$; \ No newline at end of file