081f3edcdf
Auth/access-control audit fixes (all pre-existing on main; none are regressions). Verified end-to-end where noted. HIGH - Thumbnail enumeration: photoAuth granted any gallery token access to any flat /thumbnails/thumb_* file, so a visitor to one gallery could enumerate another (password-protected) gallery's entire thumbnail set. Scope thumbnail access to the token's event via photos.thumbnail_path. Live-verified: cross-event fetch now 404s, own-event still 200s. - Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied event ids with no owner filter (single-event routes enforce requireEventOwnership), letting admin/editor archive or cascade-delete any event. Add filterOwnedEventIds; also guard rename + import-external; tighten photo-retry to scope admin (not just editor). Fix misleading bulk-delete comment. MED - verifyGalleryAccess never checked decoded.type — assert 'gallery' instead of relying on other token types incidentally lacking eventId. - secure-images generate-token/secure-download missing denySlideshowToken (#646 bypass): a leaked slideshow token could download originals. - Frontend: AuthenticatedImage + api.ts attached the gallery bearer token to absolute/external URLs — only attach to relative same-app paths. LOW hardening - Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls. - crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe). - Remove dead photoAuth import in galleryFeedback. Tests: new regression suites for thumbnail scoping + filterOwnedEventIds; fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens carry type:'gallery'). Full backend suite at the pre-existing baseline (5 suites/27 tests fail on main too), zero new failures.
298 lines
8.1 KiB
TypeScript
298 lines
8.1 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { buildResourceUrl } from '../../utils/url';
|
|
import {
|
|
getActiveGallerySlug,
|
|
getGalleryToken,
|
|
inferGallerySlugFromLocation,
|
|
resolveSlugFromRequestUrl,
|
|
} from '../../utils/galleryAuthStorage';
|
|
|
|
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
|
|
src: string;
|
|
fallbackSrc?: string;
|
|
useWatermark?: boolean;
|
|
isGallery?: boolean;
|
|
protectFromDownload?: boolean;
|
|
slug?: string;
|
|
photoId?: number;
|
|
requiresToken?: boolean;
|
|
secureUrlTemplate?: string;
|
|
downloadUrlTemplate?: string;
|
|
onProtectionViolation?: (violationType: string) => void;
|
|
watermarkText?: string;
|
|
overlayProtection?: boolean;
|
|
fragmentGrid?: boolean;
|
|
scrambleFragments?: boolean;
|
|
useCanvasRendering?: boolean;
|
|
blockKeyboardShortcuts?: boolean;
|
|
detectPrintScreen?: boolean;
|
|
detectDevTools?: boolean;
|
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
|
useEnhancedProtection?: boolean;
|
|
onLoad?: () => void;
|
|
}
|
|
|
|
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|
src,
|
|
fallbackSrc,
|
|
alt,
|
|
useWatermark = false,
|
|
isGallery = false,
|
|
protectFromDownload,
|
|
slug,
|
|
photoId,
|
|
requiresToken,
|
|
secureUrlTemplate,
|
|
downloadUrlTemplate,
|
|
onProtectionViolation,
|
|
watermarkText,
|
|
overlayProtection,
|
|
fragmentGrid,
|
|
scrambleFragments,
|
|
useCanvasRendering,
|
|
blockKeyboardShortcuts,
|
|
detectPrintScreen,
|
|
detectDevTools,
|
|
protectionLevel,
|
|
useEnhancedProtection,
|
|
onLoad,
|
|
...props
|
|
}) => {
|
|
const unusedProps = {
|
|
protectFromDownload,
|
|
photoId,
|
|
requiresToken,
|
|
secureUrlTemplate,
|
|
downloadUrlTemplate,
|
|
onProtectionViolation,
|
|
watermarkText,
|
|
overlayProtection,
|
|
fragmentGrid,
|
|
scrambleFragments,
|
|
blockKeyboardShortcuts,
|
|
detectPrintScreen,
|
|
detectDevTools,
|
|
protectionLevel,
|
|
useEnhancedProtection
|
|
};
|
|
void unusedProps;
|
|
|
|
const [imageSrc, setImageSrc] = useState<string>('');
|
|
const [error, setError] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [canvasReady, setCanvasReady] = useState(false);
|
|
const [canvasFailed, setCanvasFailed] = useState(false);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
|
|
// Draw image to canvas when canvas rendering is enabled
|
|
const drawToCanvas = useCallback(() => {
|
|
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const img = imageRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
if (!ctx || !img.complete || img.naturalWidth === 0) return;
|
|
|
|
// Set canvas dimensions to match image
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
|
|
// Draw the image
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
setCanvasReady(true);
|
|
}, [useCanvasRendering]);
|
|
|
|
useEffect(() => {
|
|
let aborted = false;
|
|
const objectUrls: string[] = [];
|
|
|
|
// Determine which token to use based on context
|
|
if (!src) {
|
|
setImageSrc(fallbackSrc || '');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(false);
|
|
setCanvasFailed(false);
|
|
setCanvasReady(false);
|
|
|
|
const resolveSlug = (candidateSrc?: string): string | null => {
|
|
if (slug) {
|
|
return slug;
|
|
}
|
|
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
|
|
if (fromUrl) {
|
|
return fromUrl;
|
|
}
|
|
return getActiveGallerySlug() || inferGallerySlugFromLocation();
|
|
};
|
|
|
|
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
|
|
if (!rawUrl) {
|
|
throw new Error('No URL provided');
|
|
}
|
|
|
|
// Build full URL for the image. Only relative paths are app-owned;
|
|
// an absolute URL is passed through untouched.
|
|
const isRelative = rawUrl.startsWith('/');
|
|
const fullImageUrl = rawUrl.startsWith('/admin')
|
|
? buildResourceUrl(`/api${rawUrl}`)
|
|
: isRelative
|
|
? buildResourceUrl(rawUrl)
|
|
: rawUrl;
|
|
|
|
const headers: Record<string, string> = {};
|
|
// Attach the gallery bearer token ONLY to relative (same-app) image
|
|
// paths. Never send it to an absolute/external URL — that would leak
|
|
// gallery credentials cross-origin. AuthenticatedImage does not
|
|
// support external URLs by design.
|
|
if (isRelative) {
|
|
const slugForRequest = resolveSlug(rawUrl);
|
|
const token = getGalleryToken(slugForRequest);
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
}
|
|
|
|
const response = await fetch(fullImageUrl, {
|
|
credentials: 'include',
|
|
headers: Object.keys(headers).length ? headers : undefined,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
objectUrls.push(objectUrl);
|
|
return objectUrl;
|
|
};
|
|
|
|
const fetchImage = async () => {
|
|
try {
|
|
const primaryUrl = await fetchWithAuth(src);
|
|
if (!aborted) {
|
|
setImageSrc(primaryUrl);
|
|
setError(false);
|
|
}
|
|
} catch (err) {
|
|
setIsLoading(false);
|
|
if (fallbackSrc && fallbackSrc !== src) {
|
|
try {
|
|
const fallbackUrl = await fetchWithAuth(fallbackSrc);
|
|
if (!aborted) {
|
|
setImageSrc(fallbackUrl);
|
|
setError(false);
|
|
}
|
|
return;
|
|
} catch (fallbackError) {
|
|
// Swallow and mark error below
|
|
}
|
|
}
|
|
if (!aborted) {
|
|
setError(true);
|
|
setImageSrc('');
|
|
}
|
|
return;
|
|
}
|
|
if (!aborted) {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchImage();
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
aborted = true;
|
|
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [src, fallbackSrc, slug]);
|
|
|
|
// Effect to draw to canvas when image is loaded and canvas rendering is enabled
|
|
useEffect(() => {
|
|
if (!useCanvasRendering || !imageSrc) return;
|
|
|
|
// Create a hidden image to load and then draw to canvas
|
|
const img = new Image();
|
|
// Only set crossOrigin for non-blob URLs (blob URLs are same-origin)
|
|
// Setting crossOrigin on blob URLs can cause silent failures
|
|
if (!imageSrc.startsWith('blob:')) {
|
|
img.crossOrigin = 'anonymous';
|
|
}
|
|
|
|
img.onload = () => {
|
|
imageRef.current = img;
|
|
drawToCanvas();
|
|
onLoad?.();
|
|
};
|
|
|
|
img.onerror = (e) => {
|
|
// Fall back to regular img if canvas loading fails
|
|
console.warn('Canvas image load failed, falling back to img tag:', e);
|
|
setCanvasFailed(true);
|
|
};
|
|
|
|
img.src = imageSrc;
|
|
|
|
return () => {
|
|
img.onload = null;
|
|
img.onerror = null;
|
|
};
|
|
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
|
|
{/* Show a placeholder while loading */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && fallbackSrc) {
|
|
return <img src={fallbackSrc} alt={alt} {...props} />;
|
|
}
|
|
|
|
if (!imageSrc) {
|
|
return null;
|
|
}
|
|
|
|
// Canvas rendering mode - only if enabled and not failed
|
|
if (useCanvasRendering && !canvasFailed) {
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={props.className}
|
|
style={{
|
|
...props.style,
|
|
// Hide canvas until it's ready to prevent flash
|
|
opacity: canvasReady ? 1 : 0,
|
|
transition: 'opacity 0.2s ease-in-out',
|
|
}}
|
|
// Prevent context menu on canvas
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
onProtectionViolation?.('canvas_context_menu');
|
|
return false;
|
|
}}
|
|
// Prevent drag
|
|
onDragStart={(e) => {
|
|
e.preventDefault();
|
|
return false;
|
|
}}
|
|
aria-label={alt}
|
|
role="img"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
|
|
};
|