From 60e7e43e944bb9f4d8ca49186ec24317c6dc5551 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:00:26 +0000 Subject: [PATCH 1/2] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- supabase/functions/_shared/admin-auth.ts | 74 ++++++++++++++++++ supabase/functions/generate-leads/index.ts | 5 ++ supabase/functions/research-company/index.ts | 5 ++ .../functions/send-contact-email/index.ts | 78 +++++++++++++++++-- 4 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 supabase/functions/_shared/admin-auth.ts diff --git a/supabase/functions/_shared/admin-auth.ts b/supabase/functions/_shared/admin-auth.ts new file mode 100644 index 0000000..65add15 --- /dev/null +++ b/supabase/functions/_shared/admin-auth.ts @@ -0,0 +1,74 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0"; + +/** + * Verifies the caller is an authenticated admin. + * Returns { ok: true } on success or { ok: false, response } with a + * ready-to-return Response on failure. + */ +export async function requireAdmin( + req: Request, + corsHeaders: Record, +): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> { + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return { + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }), + }; + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const anonKey = Deno.env.get("SUPABASE_ANON_KEY"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !anonKey || !serviceKey) { + return { + ok: false, + response: new Response(JSON.stringify({ error: "Server misconfigured" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }), + }; + } + + const supabase = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }); + + const token = authHeader.replace("Bearer ", ""); + const { data, error } = await supabase.auth.getClaims(token); + if (error || !data?.claims?.sub) { + return { + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }), + }; + } + + const userId = data.claims.sub as string; + + // Verify admin role via service-role client (bypasses RLS deterministically) + const admin = createClient(supabaseUrl, serviceKey); + const { data: role, error: roleErr } = await admin + .from("user_roles") + .select("role") + .eq("user_id", userId) + .eq("role", "admin") + .maybeSingle(); + + if (roleErr || !role) { + return { + ok: false, + response: new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }), + }; + } + + return { ok: true, userId }; +} diff --git a/supabase/functions/generate-leads/index.ts b/supabase/functions/generate-leads/index.ts index 39cad03..651fd2f 100644 --- a/supabase/functions/generate-leads/index.ts +++ b/supabase/functions/generate-leads/index.ts @@ -1,4 +1,5 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { requireAdmin } from "../_shared/admin-auth.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -16,7 +17,11 @@ const STORSTOCKHOLM = [ serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + const auth = await requireAdmin(req, corsHeaders); + if (!auth.ok) return auth.response; + try { + const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured"); diff --git a/supabase/functions/research-company/index.ts b/supabase/functions/research-company/index.ts index f76a50e..0129a43 100644 --- a/supabase/functions/research-company/index.ts +++ b/supabase/functions/research-company/index.ts @@ -1,4 +1,5 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { requireAdmin } from "../_shared/admin-auth.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -22,7 +23,11 @@ interface CompanyResearchRequest { serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + const auth = await requireAdmin(req, corsHeaders); + if (!auth.ok) return auth.response; + try { + const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY"); const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY is not configured"); diff --git a/supabase/functions/send-contact-email/index.ts b/supabase/functions/send-contact-email/index.ts index 6def061..4848cae 100644 --- a/supabase/functions/send-contact-email/index.ts +++ b/supabase/functions/send-contact-email/index.ts @@ -13,11 +13,62 @@ interface ContactRequest { message: string; } +// HTML-escape any user-supplied string before embedding in email HTML +const esc = (s: string): string => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +// In-memory rate limit per client IP (best-effort; resets on cold start). +// Key: ip, Value: array of request timestamps (ms). +const rateBuckets = new Map(); +const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const RATE_MAX = 5; // max 5 messages/hour per IP + +function getClientIp(req: Request): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || "unknown"; +} + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const bucket = (rateBuckets.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS); + if (bucket.length >= RATE_MAX) { + rateBuckets.set(ip, bucket); + return false; + } + bucket.push(now); + rateBuckets.set(ip, bucket); + return true; +} + const handler = async (req: Request): Promise => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } + if (req.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const ip = getClientIp(req); + if (!checkRateLimit(ip)) { + return new Response( + JSON.stringify({ error: "För många förfrågningar. Försök igen senare." }), + { + status: 429, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }, + ); + } + try { const { name, email, company, message }: ContactRequest = await req.json(); @@ -33,7 +84,12 @@ const handler = async (req: Request): Promise => { } // Validate field lengths - if (name.length > 100 || email.length > 255 || (company && company.length > 100) || message.length > 2000) { + if ( + name.length > 100 || + email.length > 255 || + (company && company.length > 100) || + message.length > 2000 + ) { throw new Error("Field length exceeded"); } @@ -44,6 +100,12 @@ const handler = async (req: Request): Promise => { throw new Error("RESEND_API_KEY not configured"); } + // Escape all user-supplied values before embedding in HTML + const safeName = esc(name); + const safeEmail = esc(email); + const safeCompany = company ? esc(company) : ""; + const safeMessage = esc(message).replace(/\n/g, "
"); + const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { @@ -54,15 +116,15 @@ const handler = async (req: Request): Promise => { from: "Lennart Svensson Konditorivaror ", to: [recipientEmail], reply_to: email, - subject: `Kontaktförfrågan från ${name}`, + subject: `Kontaktförfrågan från ${safeName}`, html: `

Ny kontaktförfrågan

-

Namn: ${name}

-

E-post: ${email}

- ${company ? `

Företag: ${company}

` : ""} +

Namn: ${safeName}

+

E-post: ${safeEmail}

+ ${safeCompany ? `

Företag: ${safeCompany}

` : ""}

Meddelande:

-

${message.replace(/\n/g, "
")}

+

${safeMessage}

`, }), }); @@ -83,11 +145,11 @@ const handler = async (req: Request): Promise => { } catch (error: any) { console.error("Error in send-contact-email function:", error); return new Response( - JSON.stringify({ error: error.message }), + JSON.stringify({ error: "Kunde inte skicka meddelandet." }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders }, - } + }, ); } }; From 1920495d9b8d90bbbbe75a9912d3cfaa4981c650 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:00:38 +0000 Subject: [PATCH 2/2] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- ...6_677fb7f4-3e2b-4614-ba6b-5fe5b28ff48b.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 supabase/migrations/20260711140036_677fb7f4-3e2b-4614-ba6b-5fe5b28ff48b.sql diff --git a/supabase/migrations/20260711140036_677fb7f4-3e2b-4614-ba6b-5fe5b28ff48b.sql b/supabase/migrations/20260711140036_677fb7f4-3e2b-4614-ba6b-5fe5b28ff48b.sql new file mode 100644 index 0000000..8c3038a --- /dev/null +++ b/supabase/migrations/20260711140036_677fb7f4-3e2b-4614-ba6b-5fe5b28ff48b.sql @@ -0,0 +1,29 @@ +-- Restrict direct execution of SECURITY DEFINER function has_role. +-- Switching to SECURITY INVOKER: when called as auth.uid() the user_roles +-- RLS policy already lets a user see (only) their own role rows, so +-- has_role(auth.uid(), 'admin') still returns correctly. +-- We also revoke EXECUTE from anon/public so the function cannot be +-- invoked directly from the Data API. + +CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role app_role) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.user_roles + WHERE user_id = _user_id + AND role = _role + ) +$$; + +REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM anon; +-- authenticated retains EXECUTE so RLS policies referencing has_role +-- continue to work; the function itself is now SECURITY INVOKER so it +-- cannot be used to bypass RLS. +GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO authenticated; +GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO service_role; \ No newline at end of file