security.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
  2. /**
  3. * SSRF Protection: Validates URLs to prevent Server-Side Request Forgery attacks
  4. * Blocks internal network addresses, cloud metadata endpoints, and non-HTTP protocols
  5. */
  6. export function isValidExternalUrl(urlString: string): { valid: boolean; error?: string } {
  7. try {
  8. const url = new URL(urlString);
  9. // Only allow HTTP and HTTPS protocols
  10. if (!['http:', 'https:'].includes(url.protocol)) {
  11. return { valid: false, error: 'Only HTTP and HTTPS protocols are allowed' };
  12. }
  13. const hostname = url.hostname.toLowerCase();
  14. // Block localhost and loopback
  15. if (['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname)) {
  16. return { valid: false, error: 'Localhost addresses are not allowed' };
  17. }
  18. // Block private IP ranges (RFC 1918)
  19. if (/^10\./.test(hostname) ||
  20. /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) ||
  21. /^192\.168\./.test(hostname)) {
  22. return { valid: false, error: 'Private IP addresses are not allowed' };
  23. }
  24. // Block link-local addresses
  25. if (/^169\.254\./.test(hostname) || /^fe80:/i.test(hostname)) {
  26. return { valid: false, error: 'Link-local addresses are not allowed' };
  27. }
  28. // Block cloud metadata endpoints
  29. const blockedHostnames = [
  30. '169.254.169.254', // AWS/GCP/Azure metadata
  31. 'metadata.google.internal', // GCP metadata
  32. 'metadata.internal', // Generic cloud metadata
  33. '100.100.100.200', // Alibaba Cloud metadata
  34. ];
  35. if (blockedHostnames.includes(hostname)) {
  36. return { valid: false, error: 'Cloud metadata endpoints are not allowed' };
  37. }
  38. // Block internal/private domain patterns
  39. if (hostname.endsWith('.internal') ||
  40. hostname.endsWith('.local') ||
  41. hostname.endsWith('.localhost')) {
  42. return { valid: false, error: 'Internal domain names are not allowed' };
  43. }
  44. return { valid: true };
  45. } catch {
  46. return { valid: false, error: 'Invalid URL format' };
  47. }
  48. }
  49. /**
  50. * Authentication helper: Verifies JWT and returns user claims
  51. * Returns null if authentication fails
  52. */
  53. export async function verifyAuth(req: Request): Promise<{ userId: string; email?: string } | null> {
  54. const authHeader = req.headers.get('Authorization');
  55. if (!authHeader?.startsWith('Bearer ')) {
  56. return null;
  57. }
  58. try {
  59. const supabase = createClient(
  60. Deno.env.get('SUPABASE_URL')!,
  61. Deno.env.get('SUPABASE_ANON_KEY')!,
  62. { global: { headers: { Authorization: authHeader } } }
  63. );
  64. const token = authHeader.replace('Bearer ', '');
  65. const { data, error } = await supabase.auth.getUser(token);
  66. if (error || !data?.user) {
  67. return null;
  68. }
  69. return {
  70. userId: data.user.id,
  71. email: data.user.email
  72. };
  73. } catch {
  74. return null;
  75. }
  76. }
  77. /**
  78. * Verifies if the authenticated user is a super user
  79. */
  80. export async function verifySuperUser(req: Request): Promise<boolean> {
  81. const auth = await verifyAuth(req);
  82. if (!auth) return false;
  83. try {
  84. const supabase = createClient(
  85. Deno.env.get('SUPABASE_URL')!,
  86. Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  87. );
  88. const { data, error } = await supabase.rpc('is_super_user', { user_email: auth.email });
  89. if (error) {
  90. console.error('Error checking super user status:', error);
  91. return false;
  92. }
  93. return data === true;
  94. } catch {
  95. return false;
  96. }
  97. }
  98. /**
  99. * Validates a cron job secret for internal service-to-service calls
  100. * Cron jobs should use a shared secret for authentication
  101. * SECURITY: Fails closed - requires CRON_SECRET to be configured
  102. */
  103. export function validateCronSecret(req: Request): boolean {
  104. const cronSecret = req.headers.get('x-cron-secret');
  105. const expectedSecret = Deno.env.get('CRON_SECRET');
  106. // SECURITY: Fail closed - require explicit configuration
  107. if (!expectedSecret) {
  108. console.error('CRON_SECRET environment variable not configured - denying access');
  109. return false;
  110. }
  111. // Constant-time comparison to prevent timing attacks
  112. if (cronSecret?.length !== expectedSecret.length) {
  113. return false;
  114. }
  115. return cronSecret === expectedSecret;
  116. }
  117. /**
  118. * Check if request is from internal Supabase service (edge function to edge function)
  119. */
  120. export function isInternalCall(req: Request): boolean {
  121. // Check for service role key in authorization
  122. const authHeader = req.headers.get('Authorization');
  123. const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
  124. if (authHeader && serviceRoleKey) {
  125. return authHeader.includes(serviceRoleKey);
  126. }
  127. return false;
  128. }