From 8dafa58c8fe636e89132b932e5e1490d4bb945eb Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 11 Jul 2026 13:46:49 +0300 Subject: [PATCH] fix(security): collapse origin-allowlist duplication and finish sanitizer/comparison seams Five API routes each reimplemented CORS/origin-allowlist checking slightly differently; extract a shared isAllowedOrigin()/corsHeaders() in src/utils/origin.ts, configured per-route policy so each route's actual (differing) rules are preserved rather than flattened. Also: sentry-webhook.ts now routes its signature check through the existing constantTimeEqual seam instead of a hand-rolled comparison; ProjectsNew.tsx and api/projects.ts now call the canonical sanitizeUrl/sanitizeEmail from projectData.ts instead of maintaining duplicate copies; notionClient.ts's five fetchers now share fileUrl/titleText/richText helpers instead of repeating Notion property-parsing inline five times. --- src/components/ProjectsNew.tsx | 691 +++++++++++++++------------ src/pages/api/donation-complete.ts | 35 +- src/pages/api/e4p-pledge-sign.ts | 33 +- src/pages/api/endorsement-request.ts | 39 +- src/pages/api/membership-complete.ts | 43 +- src/pages/api/pipe.ts | 17 +- src/pages/api/projects.ts | 32 +- src/pages/api/sentry-webhook.ts | 19 +- src/store/notionClient.ts | 92 ++-- src/utils/origin.ts | 33 ++ 10 files changed, 560 insertions(+), 474 deletions(-) create mode 100644 src/utils/origin.ts diff --git a/src/components/ProjectsNew.tsx b/src/components/ProjectsNew.tsx index 6aef0726..f6dc24df 100644 --- a/src/components/ProjectsNew.tsx +++ b/src/components/ProjectsNew.tsx @@ -30,6 +30,7 @@ import EmailIcon from "@mui/icons-material/Email"; import LanguageIcon from "@mui/icons-material/Language"; import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; import GroupsIcon from "@mui/icons-material/Groups"; +import { sanitizeUrl, sanitizeEmail } from "./projects/projectData"; interface Tag { id: number; @@ -78,25 +79,6 @@ interface ProjectsNewProps { availableTags?: Tag[]; } -/** Only allow http: and https: URLs to prevent javascript: / data: XSS vectors. Returns empty string for unsafe/invalid URLs. */ -const sanitizeUrl = (url: string | undefined): string => { - if (!url) return ""; - try { - const parsed = new URL(url, "https://placeholder.invalid"); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - return url; - } - } catch { - // malformed URL - } - return ""; -}; - -const sanitizeEmail = (email: string | undefined): string => { - if (!email) return ""; - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : ""; -}; - const getInitials = (name: string): string => { const words = name.trim().split(/\s+/); if (words.length >= 2) { @@ -205,7 +187,11 @@ const SOCIAL_FIELDS: SocialField[] = [ { key: "blueskyUrl", label: "Bluesky", icon: }, { key: "tiktokUrl", label: "TikTok", icon: }, { key: "signalUrl", label: "Signal", icon: }, - { key: "upscrolledUrl", label: "Upscrolled", icon: }, + { + key: "upscrolledUrl", + label: "Upscrolled", + icon: , + }, { key: "discordUsername", label: "Discord", icon: , isDiscord: true }, { key: "publicEmail", label: "Email", icon: , isEmail: true }, ]; @@ -269,7 +255,9 @@ export default function ProjectsNew({ const data = await response.json(); const projects = data.projects ?? data; const tags = data.tags ?? []; - console.log(`[ProjectsNew] Successfully fetched ${projects.length} projects, ${tags.length} tags`); + console.log( + `[ProjectsNew] Successfully fetched ${projects.length} projects, ${tags.length} tags` + ); setProjects(projects); setAvailableTags(tags); } else { @@ -326,8 +314,7 @@ export default function ProjectsNew({ getProjectText(project).toLowerCase().includes(searchQuery.toLowerCase()); const matchesTags = - activeTagIds.size === 0 || - project.tags?.some((t) => activeTagIds.has(t.id)); + activeTagIds.size === 0 || project.tags?.some((t) => activeTagIds.has(t.id)); return matchesSearch && matchesTags; }); @@ -362,7 +349,9 @@ export default function ProjectsNew({ isOptionEqualToValue={(option, value) => option.id === value.id} renderTags={(value, getTagProps) => value.map((option, index) => { - const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === option.id)); + const { bgcolor, color } = getTagColor( + availableTags.findIndex((t) => t.id === option.id) + ); const { key, ...tagProps } = getTagProps({ index }); return ( { - const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === option.id)); - const { key, ...optionProps } = props as { key: React.Key } & React.HTMLAttributes; + const { bgcolor, color } = getTagColor( + availableTags.findIndex((t) => t.id === option.id) + ); + const { key, ...optionProps } = props as { + key: React.Key; + } & React.HTMLAttributes; return (
  • setFailedImages((prev) => new Set(prev).add(project.id))} /> )} - + {project.name} {project.leadName && ( - + Led by {project.leadName} )} - + {getProjectText(project)} {sanitizeUrl(project.websiteUrl) && ( @@ -597,7 +605,12 @@ export default function ProjectsNew({ {project.featured && isFiltering && ( - + )} @@ -608,11 +621,18 @@ export default function ProjectsNew({ )} {project.tags?.slice(0, 3).map((tag) => { - const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === tag.id)); + const { bgcolor, color } = getTagColor( + availableTags.findIndex((t) => t.id === tag.id) + ); return ( {/* Footer row */} - + {visibleSocials.map((field) => ( ))} {overflowCount > 0 && ( - + +{overflowCount} )} @@ -693,313 +723,342 @@ export default function ProjectsNew({ {/* Project Details Dialog */} - {selectedProject && (() => { - const hasLogo = - selectedProject.logoUrl && - selectedProject.logoUrl !== "/images/default.jpg" && - !dialogLogoFailed; - const logoSrc = hasLogo ? resolveLogoSrc(selectedProject.logoUrl) : ""; - const hasLeaderPhotoInDialog = !!selectedProject.leaderPhoto && !dialogLeaderPhotoFailed; - const leaderPhotoSrcDialog = hasLeaderPhotoInDialog - ? resolveLogoSrc(selectedProject.leaderPhoto) - : ""; - const showDialogInitials = !hasLogo && !hasLeaderPhotoInDialog; - // Project logo takes priority; leader photo only fills in when there's no project logo - const dialogAvatarSrc = hasLogo ? logoSrc : leaderPhotoSrcDialog; + {selectedProject && + (() => { + const hasLogo = + selectedProject.logoUrl && + selectedProject.logoUrl !== "/images/default.jpg" && + !dialogLogoFailed; + const logoSrc = hasLogo ? resolveLogoSrc(selectedProject.logoUrl) : ""; + const hasLeaderPhotoInDialog = + !!selectedProject.leaderPhoto && !dialogLeaderPhotoFailed; + const leaderPhotoSrcDialog = hasLeaderPhotoInDialog + ? resolveLogoSrc(selectedProject.leaderPhoto) + : ""; + const showDialogInitials = !hasLogo && !hasLeaderPhotoInDialog; + // Project logo takes priority; leader photo only fills in when there's no project logo + const dialogAvatarSrc = hasLogo ? logoSrc : leaderPhotoSrcDialog; - const ctaCount = [ - sanitizeUrl(selectedProject.websiteUrl), - sanitizeUrl(selectedProject.donationUrl), - sanitizeUrl(selectedProject.involvementUrl), - sanitizeEmail(selectedProject.publicEmail), - ].filter(Boolean).length; + const ctaCount = [ + sanitizeUrl(selectedProject.websiteUrl), + sanitizeUrl(selectedProject.donationUrl), + sanitizeUrl(selectedProject.involvementUrl), + sanitizeEmail(selectedProject.publicEmail), + ].filter(Boolean).length; - const activeSocials = getActiveSocialFields(selectedProject); - const joinedDate = formatMonthYear(selectedProject.createdAt); - const updatedDate = formatDate(selectedProject.updatedAt); + const activeSocials = getActiveSocialFields(selectedProject); + const joinedDate = formatMonthYear(selectedProject.createdAt); + const updatedDate = formatDate(selectedProject.updatedAt); - return ( - <> - - - {showDialogInitials ? ( - - {getInitials(selectedProject.name)} - - ) : ( - { - if (hasLeaderPhotoInDialog) { - setDialogLeaderPhotoFailed(true); - } else { - setDialogLogoFailed(true); - } - }} - /> - )} - - - - {selectedProject.name} - - {selectedProject.featured && ( - - )} - - - {[selectedProject.categoryName, joinedDate ? `Joined ${joinedDate}` : ""].filter(Boolean).join(" · ")} - - - - - - - - - - {/* Primary CTA row */} - {ctaCount > 0 && ( - - {sanitizeUrl(selectedProject.websiteUrl) && ( - - )} - {sanitizeUrl(selectedProject.donationUrl) && ( - - )} - {sanitizeUrl(selectedProject.involvementUrl) && ( - - )} - {sanitizeEmail(selectedProject.publicEmail) && ( - + {getInitials(selectedProject.name)} + + ) : ( + { + if (hasLeaderPhotoInDialog) { + setDialogLeaderPhotoFailed(true); + } else { + setDialogLogoFailed(true); + } + }} + /> )} - - )} - - {/* About the project */} - - {selectedProject.description} - - - {/* Our Impact callout */} - {selectedProject.impactStatement && ( - - - Our Impact - - - {selectedProject.impactStatement} - - - )} - - {/* About the leader */} - {selectedProject.leaderBio && ( - - - About the leader - - - {selectedProject.leaderPhoto && !dialogLeaderPhotoFailed && ( - setDialogLeaderPhotoFailed(true)} - /> - )} - - {selectedProject.leaderBio} + + + + {selectedProject.name} + + {selectedProject.featured && ( + + )} + + + {[selectedProject.categoryName, joinedDate ? `Joined ${joinedDate}` : ""] + .filter(Boolean) + .join(" · ")} - )} + + + + - {/* Connect */} - {activeSocials.length > 0 && ( - - - Connect - - - {activeSocials.map((field) => ( + + {/* Primary CTA row */} + {ctaCount > 0 && ( + + {sanitizeUrl(selectedProject.websiteUrl) && ( + )} + {sanitizeUrl(selectedProject.donationUrl) && ( + - ))} + )} + {sanitizeUrl(selectedProject.involvementUrl) && ( + + )} + {sanitizeEmail(selectedProject.publicEmail) && ( + + )} - - )} + )} - {/* Tags */} - {selectedProject.tags && selectedProject.tags.length > 0 && ( - - + {selectedProject.description} + + + {/* Our Impact callout */} + {selectedProject.impactStatement && ( + - Tags - - - {selectedProject.tags.map((tag) => { - const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === tag.id)); - return ( - + Our Impact + + + {selectedProject.impactStatement} + + + )} + + {/* About the leader */} + {selectedProject.leaderBio && ( + + + About the leader + + + {selectedProject.leaderPhoto && !dialogLeaderPhotoFailed && ( + setDialogLeaderPhotoFailed(true)} /> - ); - })} + )} + + {selectedProject.leaderBio} + + - - )} - + )} - - - Last updated {updatedDate} - - + ))} + + + )} + + {/* Tags */} + {selectedProject.tags && selectedProject.tags.length > 0 && ( + + + Tags + + + {selectedProject.tags.map((tag) => { + const { bgcolor, color } = getTagColor( + availableTags.findIndex((t) => t.id === tag.id) + ); + return ( + + ); + })} + + + )} + + + - Close - - - - ); - })()} + + Last updated {updatedDate} + + + + + ); + })()} ); diff --git a/src/pages/api/donation-complete.ts b/src/pages/api/donation-complete.ts index 7f5b54a2..6768695f 100644 --- a/src/pages/api/donation-complete.ts +++ b/src/pages/api/donation-complete.ts @@ -2,41 +2,34 @@ import type { APIRoute } from "astro"; import * as Sentry from "@sentry/astro"; import { reportError } from "../../lib/report-error"; import { getEnv } from "../../utils/getEnv"; +import { + isAllowedOrigin, + corsHeaders, + ALLOWED_ORIGIN, + type OriginPolicy, +} from "../../utils/origin"; export const prerender = false; -const ALLOWED_ORIGINS = [ - "https://techforpalestine.org", - ...(import.meta.env.PROD ? [] : ["http://localhost:4321"]), -]; - -function isAllowedOrigin(origin: string | null): origin is string { - if (!origin) return false; - if (ALLOWED_ORIGINS.includes(origin)) return true; - return /\.website-aun\.pages\.dev$/.test(new URL(origin).hostname); -} +const ORIGIN_POLICY: OriginPolicy = { + allowedOrigins: [ALLOWED_ORIGIN, ...(import.meta.env.PROD ? [] : ["http://localhost:4321"])], + allowedSuffixes: [".website-aun.pages.dev"], +}; const EO_MEMBERS_LIST_URL = "https://emailoctopus.com/api/1.6/lists/8adc2ed4-f798-11ef-b60f-115427c25a1c/contacts"; const MAX_NAME_LENGTH = 200; -function corsHeaders(origin: string) { - return { - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - } as const; -} - export const POST: APIRoute = async ({ request, locals }) => { const origin = request.headers.get("Origin"); - if (!isAllowedOrigin(origin)) { + if (!isAllowedOrigin(origin, ORIGIN_POLICY)) { return new Response(JSON.stringify({ message: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" }, }); } - const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } }).runtime?.ctx; + const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } }) + .runtime?.ctx; let body: Record; try { @@ -105,7 +98,7 @@ export const POST: APIRoute = async ({ request, locals }) => { export const OPTIONS: APIRoute = async ({ request }) => { const origin = request.headers.get("Origin"); - if (!isAllowedOrigin(origin)) { + if (!isAllowedOrigin(origin, ORIGIN_POLICY)) { return new Response(null, { status: 403 }); } return new Response(null, { status: 200, headers: corsHeaders(origin) }); diff --git a/src/pages/api/e4p-pledge-sign.ts b/src/pages/api/e4p-pledge-sign.ts index d8244495..268ce6c6 100644 --- a/src/pages/api/e4p-pledge-sign.ts +++ b/src/pages/api/e4p-pledge-sign.ts @@ -3,13 +3,14 @@ import * as Sentry from "@sentry/astro"; import { Client } from "@notionhq/client"; import { getEnv } from "../../utils/getEnv.js"; import { reportError } from "../../lib/report-error"; +import { isAllowedOrigin, corsHeaders } from "../../utils/origin"; export const prerender = false; export const POST: APIRoute = async ({ request, locals }) => { const ctx = locals.runtime?.ctx; const origin = request.headers.get("Origin"); - if (origin !== "https://techforpalestine.org") { + if (!isAllowedOrigin(origin)) { return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" }, @@ -43,7 +44,7 @@ export const POST: APIRoute = async ({ request, locals }) => { status: 400, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://techforpalestine.org", + "Access-Control-Allow-Origin": origin, }, } ); @@ -54,14 +55,16 @@ export const POST: APIRoute = async ({ request, locals }) => { if (!emailRx.test(pledgeData.email)) { return new Response(JSON.stringify({ error: "Invalid email address" }), { status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, }); } - try { new URL(pledgeData.linkedin); } catch { + try { + new URL(pledgeData.linkedin); + } catch { return new Response(JSON.stringify({ error: "Invalid LinkedIn URL" }), { status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, }); } @@ -73,10 +76,13 @@ export const POST: APIRoute = async ({ request, locals }) => { ]; for (const [field, value] of textFields) { if (value.length > MAX) { - return new Response(JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }), { - status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, - }); + return new Response( + JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }), + { + status: 400, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, + } + ); } } @@ -156,12 +162,7 @@ export const POST: APIRoute = async ({ request, locals }) => { }), { status: 201, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://techforpalestine.org", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Content-Type", - }, + headers: { "Content-Type": "application/json", ...corsHeaders(origin, "POST") }, } ); } catch (error) { @@ -172,7 +173,7 @@ export const POST: APIRoute = async ({ request, locals }) => { status: 500, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://techforpalestine.org", + "Access-Control-Allow-Origin": origin, }, }); } diff --git a/src/pages/api/endorsement-request.ts b/src/pages/api/endorsement-request.ts index 7da54768..4470c53a 100644 --- a/src/pages/api/endorsement-request.ts +++ b/src/pages/api/endorsement-request.ts @@ -3,13 +3,14 @@ import * as Sentry from "@sentry/astro"; import { Client } from "@notionhq/client"; import { getEnv } from "../../utils/getEnv.js"; import { reportError } from "../../lib/report-error"; +import { isAllowedOrigin, corsHeaders } from "../../utils/origin"; export const prerender = false; export const POST: APIRoute = async ({ request, locals }) => { const ctx = locals.runtime?.ctx; const origin = request.headers.get("Origin"); - if (origin !== "https://techforpalestine.org") { + if (!isAllowedOrigin(origin)) { return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" }, @@ -48,7 +49,7 @@ export const POST: APIRoute = async ({ request, locals }) => { status: 400, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://techforpalestine.org", + "Access-Control-Allow-Origin": origin, }, }); } @@ -58,21 +59,25 @@ export const POST: APIRoute = async ({ request, locals }) => { if (!emailRx.test(endorsementData.contactEmail)) { return new Response(JSON.stringify({ error: "Invalid email address" }), { status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, }); } - try { new URL(endorsementData.organizationWebsite); } catch { + try { + new URL(endorsementData.organizationWebsite); + } catch { return new Response(JSON.stringify({ error: "Invalid organization website URL" }), { status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, }); } - try { new URL(endorsementData.campaignLink); } catch { + try { + new URL(endorsementData.campaignLink); + } catch { return new Response(JSON.stringify({ error: "Invalid campaign link URL" }), { status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, }); } @@ -87,10 +92,13 @@ export const POST: APIRoute = async ({ request, locals }) => { ]; for (const [field, value] of textFields) { if (value.length > MAX) { - return new Response(JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }), { - status: 400, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" }, - }); + return new Response( + JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }), + { + status: 400, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin }, + } + ); } } @@ -191,12 +199,7 @@ export const POST: APIRoute = async ({ request, locals }) => { }), { status: 201, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://techforpalestine.org", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Content-Type", - }, + headers: { "Content-Type": "application/json", ...corsHeaders(origin, "POST") }, } ); } catch (error) { @@ -207,7 +210,7 @@ export const POST: APIRoute = async ({ request, locals }) => { status: 500, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://techforpalestine.org", + "Access-Control-Allow-Origin": origin, }, }); } diff --git a/src/pages/api/membership-complete.ts b/src/pages/api/membership-complete.ts index b55cefa1..b5b7e9c3 100644 --- a/src/pages/api/membership-complete.ts +++ b/src/pages/api/membership-complete.ts @@ -2,41 +2,34 @@ import type { APIRoute } from "astro"; import * as Sentry from "@sentry/astro"; import { reportError } from "../../lib/report-error"; import { getEnv } from "../../utils/getEnv"; +import { + isAllowedOrigin, + corsHeaders, + ALLOWED_ORIGIN, + type OriginPolicy, +} from "../../utils/origin"; export const prerender = false; -const ALLOWED_ORIGINS = [ - "https://techforpalestine.org", - ...(import.meta.env.PROD ? [] : ["http://localhost:4321"]), -]; - -function isAllowedOrigin(origin: string | null): origin is string { - if (!origin) return false; - if (ALLOWED_ORIGINS.includes(origin)) return true; - return /\.website-aun\.pages\.dev$/.test(new URL(origin).hostname); -} +const ORIGIN_POLICY: OriginPolicy = { + allowedOrigins: [ALLOWED_ORIGIN, ...(import.meta.env.PROD ? [] : ["http://localhost:4321"])], + allowedSuffixes: [".website-aun.pages.dev"], +}; const EO_MEMBERS_LIST_URL = "https://emailoctopus.com/api/1.6/lists/8adc2ed4-f798-11ef-b60f-115427c25a1c/contacts"; const MAX_NAME_LENGTH = 200; -function corsHeaders(origin: string) { - return { - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - } as const; -} - export const POST: APIRoute = async ({ request, locals }) => { const origin = request.headers.get("Origin"); - if (!isAllowedOrigin(origin)) { + if (!isAllowedOrigin(origin, ORIGIN_POLICY)) { return new Response(JSON.stringify({ message: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" }, }); } - const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } }).runtime?.ctx; + const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } }) + .runtime?.ctx; let body: Record; try { @@ -84,7 +77,9 @@ export const POST: APIRoute = async ({ request, locals }) => { }); } }) - .catch((err) => reportError(err, { context: "membership-complete hub", email: redacted })) + .catch((err) => + reportError(err, { context: "membership-complete hub", email: redacted }) + ) : Promise.resolve(), eoApiKey @@ -110,7 +105,9 @@ export const POST: APIRoute = async ({ request, locals }) => { }); } }) - .catch((err) => reportError(err, { context: "membership-complete eo", email: redacted })) + .catch((err) => + reportError(err, { context: "membership-complete eo", email: redacted }) + ) : Promise.resolve(), ]); @@ -131,7 +128,7 @@ export const POST: APIRoute = async ({ request, locals }) => { export const OPTIONS: APIRoute = async ({ request }) => { const origin = request.headers.get("Origin"); - if (!isAllowedOrigin(origin)) { + if (!isAllowedOrigin(origin, ORIGIN_POLICY)) { return new Response(null, { status: 403 }); } return new Response(null, { status: 200, headers: corsHeaders(origin) }); diff --git a/src/pages/api/pipe.ts b/src/pages/api/pipe.ts index 46a65bc3..40cab17a 100644 --- a/src/pages/api/pipe.ts +++ b/src/pages/api/pipe.ts @@ -1,15 +1,14 @@ import type { APIRoute } from "astro"; import * as Sentry from "@sentry/astro"; import { reportError } from "../../lib/report-error"; +import { isAllowedOrigin, type OriginPolicy } from "../../utils/origin"; -const ALLOWED_ORIGIN = "https://techforpalestine.org"; -const PREVIEW_ORIGIN_SUFFIX = ".pages.dev"; const PLAUSIBLE_API = "https://plausible.io/api/event"; const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate", "Membership-complete"]); - -function isAllowedOrigin(origin: string): boolean { - return origin === ALLOWED_ORIGIN || origin.endsWith(PREVIEW_ORIGIN_SUFFIX); -} +const ORIGIN_POLICY: OriginPolicy = { + allowedSuffixes: [".pages.dev"], + allowMissingOrigin: true, +}; function parseEventName(body: string): string { try { @@ -23,7 +22,7 @@ function parseEventName(body: string): string { export const POST: APIRoute = async ({ request, locals }) => { const ctx = locals.runtime?.ctx; const origin = request.headers.get("origin"); - if (origin && !isAllowedOrigin(origin)) { + if (!isAllowedOrigin(origin, ORIGIN_POLICY)) { return new Response("Forbidden", { status: 403 }); } @@ -73,7 +72,9 @@ export const POST: APIRoute = async ({ request, locals }) => { const key = `dropped:${date}:${time}:${id}`; let parsed: Record = {}; - try { parsed = JSON.parse(body); } catch {} + try { + parsed = JSON.parse(body); + } catch {} const value = JSON.stringify({ eventName, diff --git a/src/pages/api/projects.ts b/src/pages/api/projects.ts index ab9ae042..2dff969e 100644 --- a/src/pages/api/projects.ts +++ b/src/pages/api/projects.ts @@ -2,29 +2,35 @@ import type { APIRoute } from "astro"; import * as Sentry from "@sentry/astro"; import { getEnv } from "../../utils/getEnv.js"; import { reportError } from "../../lib/report-error"; +import { sanitizeUrl } from "../../components/projects/projectData.js"; export const prerender = false; const URL_FIELDS = [ - "websiteUrl", "logoUrl", "twitterUrl", "linkedinUrl", "githubUrl", - "instagramUrl", "facebookUrl", "youtubeUrl", "telegramUrl", - "mastodonUrl", "blueskyUrl", "tiktokUrl", "signalUrl", "upscrolledUrl", - "leaderPhoto", "donationUrl", "involvementUrl", + "websiteUrl", + "logoUrl", + "twitterUrl", + "linkedinUrl", + "githubUrl", + "instagramUrl", + "facebookUrl", + "youtubeUrl", + "telegramUrl", + "mastodonUrl", + "blueskyUrl", + "tiktokUrl", + "signalUrl", + "upscrolledUrl", + "leaderPhoto", + "donationUrl", + "involvementUrl", ] as const; -/** Strip any URL field that isn't http(s) to prevent XSS via javascript: or data: URIs. */ function sanitizeProjectUrls(project: Record): Record { for (const field of URL_FIELDS) { const val = project[field]; if (typeof val !== "string") continue; - try { - const parsed = new URL(val, "https://placeholder.invalid"); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - project[field] = undefined; - } - } catch { - project[field] = undefined; - } + project[field] = sanitizeUrl(val) || undefined; } return project; } diff --git a/src/pages/api/sentry-webhook.ts b/src/pages/api/sentry-webhook.ts index aeaa84c1..1f4e1e20 100644 --- a/src/pages/api/sentry-webhook.ts +++ b/src/pages/api/sentry-webhook.ts @@ -1,11 +1,8 @@ import type { APIRoute } from "astro"; import { getEnv } from "../../utils/getEnv"; +import { constantTimeEqual } from "../../utils/crypto"; -async function verifySignature( - secret: string, - body: string, - signature: string -): Promise { +async function verifySignature(secret: string, body: string, signature: string): Promise { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), @@ -17,13 +14,7 @@ async function verifySignature( const expected = Array.from(new Uint8Array(mac)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); - // constant-time comparison - if (expected.length !== signature.length) return false; - let diff = 0; - for (let i = 0; i < expected.length; i++) { - diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i); - } - return diff === 0; + return constantTimeEqual(expected, signature); } function formatMessage(body: Record): string { @@ -88,7 +79,9 @@ export const POST: APIRoute = async ({ request, locals }) => { const mmChannelId = getEnv("MATTERMOST_CHANNEL_ID", locals); if (!secret || !mmUrl || !mmToken || !mmChannelId) { - console.error("Missing SENTRY_WEBHOOK_SECRET, MATTERMOST_URL, MATTERMOST_BOT_TOKEN, or MATTERMOST_CHANNEL_ID"); + console.error( + "Missing SENTRY_WEBHOOK_SECRET, MATTERMOST_URL, MATTERMOST_BOT_TOKEN, or MATTERMOST_CHANNEL_ID" + ); return new Response(JSON.stringify({ error: "Not configured" }), { status: 500 }); } diff --git a/src/store/notionClient.ts b/src/store/notionClient.ts index f4bb2a6c..e5d4f756 100644 --- a/src/store/notionClient.ts +++ b/src/store/notionClient.ts @@ -13,6 +13,35 @@ function createNotionAxios(secret: string) { }); } +interface NotionFilesProperty { + files?: Array< + { type: "external"; external: { url: string } } | { type: "file"; file: { url: string } } + >; +} + +interface NotionTitleProperty { + title?: Array<{ plain_text: string }>; +} + +interface NotionRichTextProperty { + rich_text?: Array<{ plain_text: string }>; +} + +function fileUrl(prop: NotionFilesProperty | undefined, fallback: string): string { + const file = prop?.files?.[0]; + if (!file) return fallback; + if (file.type === "external") return file.external.url; + return file.file.url; +} + +function titleText(prop: NotionTitleProperty | undefined, fallback = ""): string { + return prop?.title?.[0]?.plain_text || fallback; +} + +function richText(prop: NotionRichTextProperty | undefined, fallback = ""): string { + return prop?.rich_text?.[0]?.plain_text || fallback; +} + export const fetchNotionEvents = async (showAll: boolean = false, locals?: any) => { const secret = getEnv("NOTION_SECRET", locals); const dbId = getEnv("NOTION_DB_ID", locals); @@ -38,28 +67,18 @@ export const fetchNotionEvents = async (showAll: boolean = false, locals?: any) const events = response.data.results.map((page: any) => { const props = page.properties; - let headerImage = ""; - if (props["Header"]?.files?.length > 0) { - const file = props["Header"].files[0]; - if (file.type === "external") { - headerImage = file.external.url; - } else if (file.type === "file") { - headerImage = file.file.url; - } - } - - const description = props["Description"]?.rich_text?.[0]?.plain_text || ""; - + const headerImage = fileUrl(props["Header"], "/images/default.jpg"); + const description = richText(props["Description"]); const registerLink = props["Link to registration"]?.url || ""; const recordingLink = props["Link to recording"]?.url || ""; return { id: page.id, - title: props["Title"]?.title?.[0]?.plain_text || "Untitled", + title: titleText(props["Title"], "Untitled"), date: props["Date of event"]?.date?.start || "", status: props["Stage"]?.select?.name || "", location: props["Type of event"]?.multi_select?.[0]?.name || "", - image: headerImage || "/images/default.jpg", + image: headerImage, link: page.url, description, registerLink, @@ -83,28 +102,18 @@ export const fetchNotionEventById = async (pageId: string, locals?: any) => { const props = response.data.properties; - // Image from "Header" files property - let headerImage = ""; - if (props["Header"]?.files?.length > 0) { - const file = props["Header"].files[0]; - if (file.type === "external") { - headerImage = file.external.url; - } else if (file.type === "file") { - headerImage = file.file.url; - } - } - - const description = props["Description"]?.rich_text?.[0]?.plain_text || ""; + const headerImage = fileUrl(props["Header"], "/images/default.jpg"); + const description = richText(props["Description"]); const registerLink = props["Link to registration"]?.url || ""; const recordingLink = props["Link to recording"]?.url || ""; return { id: response.data.id, - title: props["Title"]?.title?.[0]?.plain_text || "Untitled", + title: titleText(props["Title"], "Untitled"), date: props["Date of event"]?.date?.start || "", status: props["Stage"]?.select?.name || "", location: props["Type of event"]?.multi_select?.[0]?.name || "", - image: headerImage || "/images/default.jpg", + image: headerImage, link: response.data.url, description, registerLink, @@ -139,7 +148,7 @@ export const fetchNotionFAQ = async (showAll: boolean = false, locals?: any) => const faqs = response.data.results.map((page: any) => { const props = page.properties; - const question = props["Question"]?.title?.[0]?.plain_text || ""; + const question = titleText(props["Question"]); const answer = props["Answer"]?.rich_text || []; const position = props["Position"]?.number ?? 999999; // Default to high number if no position @@ -180,7 +189,7 @@ export const fetchNotionIdeas = async (locals?: any) => { const ideas = response.data.results.map((page: any) => { const props = page.properties; - const name = props["Name"]?.title?.[0]?.plain_text || ""; + const name = titleText(props["Name"]); const category = props["Category"]?.select?.name || ""; const description = props["Description"]?.rich_text || []; @@ -222,23 +231,14 @@ export const fetchNotionAgenda = async (locals?: any) => { const speakerResponse = await notionAxios.get(`pages/${modId}`); const props = speakerResponse.data.properties; - const name = props["Name"]?.title?.[0]?.plain_text || ""; - const title = props["Title"]?.rich_text?.[0]?.plain_text || ""; + const name = titleText(props["Name"]); + const title = richText(props["Title"]); // Concatenate all rich_text blocks for bio const bioArray = props["Speaker bio"]?.rich_text || []; const bio = bioArray.map((block: any) => block.plain_text).join(""); - // Get photo from files property - let photo = ""; - if (props["Photo"]?.files?.length > 0) { - const file = props["Photo"].files[0]; - if (file.type === "external") { - photo = file.external.url; - } else if (file.type === "file") { - photo = file.file.url; - } - } + const photo = fileUrl(props["Photo"], "/images/default.jpg"); return { id: modId, @@ -247,7 +247,7 @@ export const fetchNotionAgenda = async (locals?: any) => { name, title, bio, - photo: photo || "/images/default.jpg", + photo, }, }; } catch (error) { @@ -267,9 +267,9 @@ export const fetchNotionAgenda = async (locals?: any) => { const agendaItems = response.data.results.map((page: any) => { const props = page.properties; - const title = props["Title"]?.title?.[0]?.plain_text || ""; - const description = props["Description"]?.rich_text?.[0]?.plain_text || ""; - const time = props["Time"]?.rich_text?.[0]?.plain_text || ""; + const title = titleText(props["Title"]); + const description = richText(props["Description"]); + const time = richText(props["Time"]); const moderatorRelations = props["Moderator"]?.relation || []; const moderator = diff --git a/src/utils/origin.ts b/src/utils/origin.ts new file mode 100644 index 00000000..85a736b0 --- /dev/null +++ b/src/utils/origin.ts @@ -0,0 +1,33 @@ +export const ALLOWED_ORIGIN = "https://techforpalestine.org"; + +export interface OriginPolicy { + readonly allowedOrigins?: readonly string[]; + readonly allowedSuffixes?: readonly string[]; + readonly allowMissingOrigin?: boolean; +} + +export function isAllowedOrigin( + origin: string | null, + policy: OriginPolicy = {} +): origin is string { + if (!origin) return policy.allowMissingOrigin ?? false; + + const allowedOrigins = policy.allowedOrigins ?? [ALLOWED_ORIGIN]; + if (allowedOrigins.includes(origin)) return true; + + if (!policy.allowedSuffixes?.length) return false; + try { + const hostname = new URL(origin).hostname; + return policy.allowedSuffixes.some((suffix) => hostname.endsWith(suffix)); + } catch { + return false; + } +} + +export function corsHeaders(origin: string, methods = "POST, OPTIONS") { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": "Content-Type", + } as const; +}