Fixed security findings
X-Lovable-Edit-ID: edt-0350ce8a-9b30-411a-a2e8-6bab59e3228d Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
@@ -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<string, string>,
|
||||
): 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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, """)
|
||||
.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<string, number[]>();
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
}
|
||||
|
||||
// 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<Response> => {
|
||||
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, "<br />");
|
||||
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -54,15 +116,15 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
from: "Lennart Svensson Konditorivaror <noreply@lennartsvensson.se>",
|
||||
to: [recipientEmail],
|
||||
reply_to: email,
|
||||
subject: `Kontaktförfrågan från ${name}`,
|
||||
subject: `Kontaktförfrågan från ${safeName}`,
|
||||
html: `
|
||||
<h2>Ny kontaktförfrågan</h2>
|
||||
<p><strong>Namn:</strong> ${name}</p>
|
||||
<p><strong>E-post:</strong> ${email}</p>
|
||||
${company ? `<p><strong>Företag:</strong> ${company}</p>` : ""}
|
||||
<p><strong>Namn:</strong> ${safeName}</p>
|
||||
<p><strong>E-post:</strong> ${safeEmail}</p>
|
||||
${safeCompany ? `<p><strong>Företag:</strong> ${safeCompany}</p>` : ""}
|
||||
<hr />
|
||||
<p><strong>Meddelande:</strong></p>
|
||||
<p>${message.replace(/\n/g, "<br />")}</p>
|
||||
<p>${safeMessage}</p>
|
||||
`,
|
||||
}),
|
||||
});
|
||||
@@ -83,11 +145,11 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
} 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 },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user