Harden auth, payments, and frontend against review findings.
Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -155,8 +156,12 @@ export default function BookingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to external booking if enabled
|
||||
if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) {
|
||||
// Redirect to external booking if enabled (only https:// targets are allowed)
|
||||
if (
|
||||
eventRes.event.externalBookingEnabled &&
|
||||
eventRes.event.externalBookingUrl &&
|
||||
isSafeExternalUrl(eventRes.event.externalBookingUrl)
|
||||
) {
|
||||
window.location.href = eventRes.event.externalBookingUrl;
|
||||
return;
|
||||
}
|
||||
@@ -473,6 +478,16 @@ export default function BookingPage() {
|
||||
paymentMethod: formData.paymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
// Fetch full payment credentials now that we hold a ticket capability token
|
||||
try {
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(
|
||||
params.eventId as string,
|
||||
primaryTicket.id
|
||||
);
|
||||
setPaymentConfig(paymentOptions);
|
||||
} catch {
|
||||
// Keep the flags-only config from initial load if the gated fetch fails
|
||||
}
|
||||
setStep('manual_payment');
|
||||
} else {
|
||||
// Cash payment - go straight to success
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function BookingPaymentPage() {
|
||||
|
||||
// Get payment config for the event
|
||||
if (ticketData.eventId) {
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id);
|
||||
setPaymentConfig(paymentOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
function LoginContent() {
|
||||
@@ -25,8 +26,8 @@ function LoginContent() {
|
||||
password: '',
|
||||
});
|
||||
|
||||
// Check for redirect after login
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||
// Check for redirect after login (only same-origin relative paths are honoured)
|
||||
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1047,6 +1047,7 @@ export default function AdminEmailsPage() {
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
sandbox=""
|
||||
className="w-full h-full min-h-[500px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
@@ -1100,6 +1101,7 @@ export default function AdminEmailsPage() {
|
||||
{selectedLog.bodyHtml ? (
|
||||
<iframe
|
||||
srcDoc={selectedLog.bodyHtml}
|
||||
sandbox=""
|
||||
className="w-full h-full min-h-[400px]"
|
||||
title="Email Content"
|
||||
/>
|
||||
|
||||
@@ -2217,7 +2217,7 @@ export default function AdminEventDetailPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe srcDoc={previewHtml} className="w-full h-full min-h-[500px]" title="Email Preview" />
|
||||
<iframe srcDoc={previewHtml} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
// Constant-time string comparison to avoid leaking the secret via response timing.
|
||||
function secretsMatch(provided: unknown, expected: string): boolean {
|
||||
if (typeof provided !== 'string' || provided.length === 0) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { secret, tag } = body;
|
||||
|
||||
// Validate the revalidation secret
|
||||
// Validate the revalidation secret. Reject if it is unset or left at an insecure default.
|
||||
const revalidateSecret = process.env.REVALIDATE_SECRET;
|
||||
if (!revalidateSecret || secret !== revalidateSecret) {
|
||||
if (!revalidateSecret || revalidateSecret === 'change-me' || revalidateSecret.length < 16) {
|
||||
return NextResponse.json({ error: 'Revalidation is not configured' }, { status: 503 });
|
||||
}
|
||||
if (!secretsMatch(secret, revalidateSecret)) {
|
||||
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
@@ -82,10 +83,9 @@ export default function GoogleSignInButton({
|
||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||
onSuccess?.();
|
||||
|
||||
// Use window.location for navigation to ensure clean state
|
||||
if (redirectTo) {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
// Use window.location for navigation to ensure clean state.
|
||||
// Constrain to a same-origin path to avoid open redirects.
|
||||
window.location.href = safeInternalPath(redirectTo, '/dashboard');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||
|
||||
@@ -21,6 +21,17 @@ function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string
|
||||
return match ? match[1].trim() : updatedAt;
|
||||
}
|
||||
|
||||
// Only permit safe link schemes. Anything else (javascript:, data:, etc.) is dropped
|
||||
// so a malicious markdown link can't execute script when clicked.
|
||||
function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const trimmed = href.trim();
|
||||
// Allow relative/anchor/protocol-relative-safe links
|
||||
if (trimmed.startsWith('/') || trimmed.startsWith('#')) return trimmed;
|
||||
if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return trimmed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function LegalPageLayout({
|
||||
slug,
|
||||
initialLocale,
|
||||
@@ -141,17 +152,20 @@ export default function LegalPageLayout({
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
// Style links
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Style links (with scheme allowlist to block javascript:/data: URLs)
|
||||
a: ({ href, children }) => {
|
||||
const safeHref = sanitizeHref(href);
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
||||
target={safeHref?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={safeHref?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Style horizontal rules
|
||||
hr: () => (
|
||||
<hr className="my-8 border-gray-200" />
|
||||
|
||||
@@ -14,11 +14,18 @@ interface RichTextEditorProps {
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
// Escape HTML-significant characters so any raw HTML embedded in the markdown source
|
||||
// is neutralised before we layer our own generated tags on top (defense-in-depth;
|
||||
// the public renderer escapes too, and TipTap sanitizes via its schema).
|
||||
function escapeRawHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Convert markdown to HTML for TipTap
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '<p></p>';
|
||||
|
||||
let html = markdown;
|
||||
let html = escapeRawHtml(markdown);
|
||||
|
||||
// Convert horizontal rules first (before other processing)
|
||||
html = html.replace(/^---+$/gm, '<hr>');
|
||||
|
||||
@@ -44,6 +44,17 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = 'spanglish-token';
|
||||
const USER_KEY = 'spanglish-user';
|
||||
const AUTH_COOKIE = 'spanglish-auth';
|
||||
|
||||
function setAuthCookie() {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function clearAuthCookie() {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -66,12 +77,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
setAuthCookie();
|
||||
} else if (res.status === 401) {
|
||||
// Token is invalid, clear auth state
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
clearAuthCookie();
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error, keep using cached data
|
||||
@@ -85,10 +98,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const savedUser = localStorage.getItem(USER_KEY);
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
// Refresh user data from server to get latest role/permissions
|
||||
refreshUser().finally(() => setIsLoading(false));
|
||||
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
|
||||
let parsedUser: User | null = null;
|
||||
try {
|
||||
parsedUser = JSON.parse(savedUser);
|
||||
} catch {
|
||||
parsedUser = null;
|
||||
}
|
||||
|
||||
if (parsedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(parsedUser);
|
||||
// Refresh user data from server to get latest role/permissions (source of truth)
|
||||
refreshUser().finally(() => setIsLoading(false));
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -99,6 +126,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(data.user);
|
||||
localStorage.setItem(TOKEN_KEY, data.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
setAuthCookie();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
@@ -166,10 +194,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
|
||||
const currentToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (currentToken) {
|
||||
fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${currentToken}` },
|
||||
}).catch(() => {
|
||||
// Ignore network errors; local state is cleared regardless.
|
||||
});
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
clearAuthCookie();
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((updatedUser: User) => {
|
||||
|
||||
@@ -314,10 +314,11 @@ export const paymentOptionsApi = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Event-specific options (merged with global)
|
||||
getForEvent: (eventId: string) =>
|
||||
// Event-specific options (merged with global). Pass ticketId after booking to
|
||||
// retrieve bank/TPago credentials gated behind the booking capability token.
|
||||
getForEvent: (eventId: string, ticketId?: string) =>
|
||||
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
|
||||
`/api/payment-options/event/${eventId}`
|
||||
`/api/payment-options/event/${eventId}${ticketId ? `?ticketId=${encodeURIComponent(ticketId)}` : ''}`
|
||||
),
|
||||
|
||||
// Event overrides (admin only)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Returns a safe internal redirect path, or the provided fallback.
|
||||
*
|
||||
* Only same-origin relative paths are allowed (must start with a single "/" and not
|
||||
* "//", which the browser treats as protocol-relative -> external). This prevents
|
||||
* open-redirect attacks via a `?redirect=` parameter.
|
||||
*/
|
||||
export function safeInternalPath(value: string | null | undefined, fallback: string = '/'): string {
|
||||
if (!value) return fallback;
|
||||
// Must be a relative path rooted at "/", but not "//" or "/\" (protocol-relative).
|
||||
if (!value.startsWith('/')) return fallback;
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
|
||||
// Reject attempts to smuggle a scheme or control characters.
|
||||
if (/[\x00-\x1f]/.test(value) || value.includes('\\')) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a URL is safe to use as an external navigation target:
|
||||
* an absolute https:// URL, or a same-origin relative path.
|
||||
*/
|
||||
export function isSafeExternalUrl(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
if (value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\')) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Defense-in-depth guard for authenticated areas.
|
||||
*
|
||||
* Auth tokens live in localStorage (not readable here), so we rely on a lightweight
|
||||
* `spanglish-auth` cookie set alongside login. The API remains the authoritative gate;
|
||||
* this only keeps unauthenticated visitors from loading the admin/dashboard JS shell.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
|
||||
const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1';
|
||||
if (!hasAuthCookie) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*', '/dashboard/:path*'],
|
||||
};
|
||||
Reference in New Issue
Block a user