Files
immich-folio/app/api/og/route.tsx
Ralksta 37f7f62157 security: fix pre-auth bypasses and restore Immich 3.x compatibility (#378)
Seven security issues from an audit, plus album pages and the map coming
up empty against Immich 3.x. Verified against a real Immich 3.0.3
instance in a production build.

Security:
- Admin session forgery via a hardcoded 'dev-fallback-secret' signing key
  that bypassed the production AUTH_SECRET guard
- /api/map ignored album passwords and returned working asset tokens;
  rewriting ?size=thumbnail to ?size=original downloaded full-resolution
  originals from protected albums
- Admin login had no rate limit, and the client IP came from a spoofable
  header, so rotating it bypassed every limit in the app
- Raw Immich asset UUIDs were serialized into the RSC payload
- Album tokens never expired server-side; a captured token replayed forever
- Path traversal into the Immich API via an unvalidated album id
- /admin ran with no enforced CSP (only Report-Only reached it)

TRUSTED_PROXIES is replaced by TRUSTED_PROXY_HOPS. The old variable
matched proxy IPs against the socket peer address, which self-hosted
Next.js never exposes, so the check could never pass: unset, the limiter
read a spoofable header; set, every request shared one bucket and a single
client could rate-limit the whole site. Migrates automatically.

Immich 3.x: GET /albums/:id no longer embeds assets. Assets now come from
POST /search/metadata with withExif: true — without that flag EXIF is
omitted and grid aspect ratios, the lightbox panel and map markers stay
empty. Drops support for Immich below 3.0, which now logs a warning.

Upgrade: requires Immich 3.0+; set TRUSTED_PROXY_HOPS (nginx = 1);
visitors of protected areas and admin sessions must log in once more.

91 unit tests (25 new); the new admin-auth and rate-limit cases were
confirmed to fail against the pre-fix code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:57:57 +02:00

109 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dynamic OG image generator — renders social share previews.
* Uses next/og (ImageResponse) to create 1200×630 cards.
* Reads accent color from theme config.
*
* GET /api/og?title=Album+Name&subtitle=12+photos
*/
import { ImageResponse } from 'next/og';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
/**
* OG image rendering runs satori + resvg per request and each unique ?title
* misses every cache tier, so this is far more expensive than an image proxy
* hit. It must not inherit RATE_LIMIT_RPM (default 1500) — that turns the
* endpoint into an unauthenticated CPU amplifier.
*/
const OG_RPM = 30;
export async function GET(request: NextRequest) {
const ip = getClientIp(request);
const { theme } = getConfig();
const { success } = checkRateLimit(`og:${ip}`, OG_RPM);
if (!success) {
return new NextResponse('Too many requests', { status: 429 });
}
const { searchParams } = request.nextUrl;
const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0a0a0a',
fontFamily: 'system-ui, -apple-system, sans-serif',
padding: '60px',
}}
>
{/* Decorative top line */}
<div
style={{
width: '60px',
height: '2px',
backgroundColor: theme.accent,
marginBottom: '40px',
}}
/>
{/* Title */}
<div
style={{
fontSize: '64px',
fontWeight: 600,
color: '#f0f0f0',
letterSpacing: '0.02em',
textAlign: 'center',
lineHeight: 1.2,
maxWidth: '900px',
}}
>
{title}
</div>
{/* Subtitle */}
{subtitle && (
<div
style={{
fontSize: '24px',
color: '#999999',
fontWeight: 300,
marginTop: '16px',
letterSpacing: '0.08em',
textTransform: 'lowercase',
}}
>
{subtitle}
</div>
)}
{/* Bottom decorative line */}
<div
style={{
width: '60px',
height: '2px',
backgroundColor: theme.accent,
marginTop: '40px',
}}
/>
</div>,
{
width: 1200,
height: 630,
headers: {
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
},
},
);
}