From f19e1bc023b3a358142cbdf5e4ff41822796ea4b Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Mon, 8 Dec 2025 01:01:38 +0100 Subject: [PATCH] feat: secure proof-of-payment download routes with shareId validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Update download links in UI to use shareId instead of locationID - Add shareId validation to per-bill proof download route - Add shareId validation to combined proof download route - Validate TTL before allowing downloads - Extract locationId from shareId using extractShareId helper Security: - Download routes now validate checksum and TTL - Prevents unauthorized access to proof-of-payment files - Returns 404 for invalid/expired share links 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../proof-of-payment/combined/[id]/route.tsx | 26 ++++++++++++-- .../proof-of-payment/per-bill/[id]/route.tsx | 34 +++++++++++++++---- app/ui/LocationCard.tsx | 15 +++++--- app/ui/ViewBillCard.tsx | 2 +- app/ui/ViewLocationCard.tsx | 2 +- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx b/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx index 820566c..bfa232d 100644 --- a/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx +++ b/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx @@ -1,19 +1,39 @@ import { getDbClient } from '@/app/lib/dbClient'; import { BillingLocation } from '@/app/lib/db-types'; import { notFound } from 'next/navigation'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; -export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) { - const locationID = id; +export async function GET(request: Request, { params: { id } }: { params: { id: string } }) { + const shareId = id; + + // Validate shareId and extract locationId + const extracted = extractShareId(shareId); + if (!extracted) { + notFound(); + } + + const { locationId: locationID, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationID, checksum)) { + notFound(); + } const dbClient = await getDbClient(); const location = await dbClient.collection("lokacije") .findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1, + shareTTL: 1, } }); - if(!location?.utilBillsProofOfPayment) { + if (!location?.utilBillsProofOfPayment) { + notFound(); + } + + // Check if sharing is active and not expired + if (!location.shareTTL || new Date() > location.shareTTL) { notFound(); } diff --git a/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx b/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx index df1c1b4..7b82301 100644 --- a/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx +++ b/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx @@ -1,12 +1,28 @@ import { getDbClient } from '@/app/lib/dbClient'; import { BillingLocation } from '@/app/lib/db-types'; import { notFound } from 'next/navigation'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; -export async function GET(_request: Request, { params:{ id } }: { params: { id:string } }) { - // Parse locationID-billID format - const [locationID, billID] = id.split('-'); +export async function GET(_request: Request, { params: { id } }: { params: { id: string } }) { + // Parse shareId-billID format + // shareId = 40 chars (locationId 24 + checksum 16) + const shareId = id.substring(0, 40); + const billID = id.substring(41); // Skip the '-' separator - if (!locationID || !billID) { + if (!shareId || !billID) { + notFound(); + } + + // Validate shareId and extract locationId + const extracted = extractShareId(shareId); + if (!extracted) { + notFound(); + } + + const { locationId: locationID, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationID, checksum)) { notFound(); } @@ -14,13 +30,19 @@ export async function GET(_request: Request, { params:{ id } }: { params: { id:s const location = await dbClient.collection("lokacije") .findOne({ _id: locationID }, { projection: { - // Don't load bill attachments, only proof of payment + // Don't load bill attachments, only proof of payment and shareTTL "bills._id": 1, "bills.proofOfPayment": 1, + "shareTTL": 1, } }); - if(!location) { + if (!location) { + notFound(); + } + + // Check if sharing is active and not expired + if (!location.shareTTL || new Date() > location.shareTTL) { notFound(); } diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index 2a6c4a8..c2e88d9 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -10,6 +10,7 @@ import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { toast } from "react-toastify"; import { get } from "http"; +import { generateShareLink } from "../lib/actions/locationActions"; export interface LocationCardProps { location: BillingLocation; @@ -33,13 +34,17 @@ export const LocationCard: FC = ({ location, currency }) => { // sum all the paid bill amounts (regardless of who pays) const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); - const handleCopyLinkClick = () => { + const handleCopyLinkClick = async () => { // copy URL to clipboard - const url = `${window.location.origin}/${currentLocale}/share/location/${_id}`; - navigator.clipboard.writeText(url); + const shareLink = await generateShareLink(_id); + + if(shareLink.error) { + toast.error(shareLink.error, { theme: "dark" }); + } else { + navigator.clipboard.writeText(shareLink.shareUrl as string); + toast.success(t("link-copy-message"), { theme: "dark" }); + } - // use NextJS toast to notiy user that the link was copied - toast.success(t("link-copy-message"), { theme: "dark" }); } return ( diff --git a/app/ui/ViewBillCard.tsx b/app/ui/ViewBillCard.tsx index 0fbc822..bcfe862 100644 --- a/app/ui/ViewBillCard.tsx +++ b/app/ui/ViewBillCard.tsx @@ -130,7 +130,7 @@ export const ViewBillCard: FC = ({ location, bill, shareId }) proofOfPaymentFilename ? (
diff --git a/app/ui/ViewLocationCard.tsx b/app/ui/ViewLocationCard.tsx index c005a4b..6d1db4c 100644 --- a/app/ui/ViewLocationCard.tsx +++ b/app/ui/ViewLocationCard.tsx @@ -195,7 +195,7 @@ export const ViewLocationCard: FC = ({ location, userSett attachmentFilename ? (