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>
285 lines
9.0 KiB
TypeScript
285 lines
9.0 KiB
TypeScript
import { env } from '../env';
|
|
import { resolveAuthSecret } from '../secret';
|
|
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
|
import { resolveTheme, VALID_LAYOUTS } from './theme';
|
|
import {
|
|
slugify,
|
|
AppConfig,
|
|
SubpageConfig,
|
|
SubpageSectionConfig,
|
|
SubpageObjectValue,
|
|
GalleryYaml,
|
|
SettingsYaml,
|
|
GridConfig,
|
|
} from './schema';
|
|
|
|
export * from './schema';
|
|
export * from './theme';
|
|
|
|
let _config: AppConfig | null = null;
|
|
|
|
/** Invalidate the cached config so the next getConfig() call re-reads YAML files. */
|
|
export function invalidateConfigCache(): void {
|
|
_config = null;
|
|
clearYamlCache();
|
|
}
|
|
|
|
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
|
export function buildSubpageGrid(raw?: {
|
|
columns?: number;
|
|
gap?: number;
|
|
aspectRatio?: string;
|
|
layout?: string;
|
|
}): { grid: Partial<GridConfig> } | Record<string, never> {
|
|
if (!raw) return {};
|
|
return {
|
|
grid: {
|
|
...(raw.columns != null ? { columns: raw.columns } : {}),
|
|
...(raw.gap != null ? { gap: raw.gap } : {}),
|
|
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
|
|
...(raw.layout != null
|
|
? {
|
|
layout: (VALID_LAYOUTS.includes(raw.layout)
|
|
? raw.layout
|
|
: 'masonry') as GridConfig['layout'],
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function getConfig(): AppConfig {
|
|
// _config is a per-worker in-memory cache.
|
|
// invalidateConfigCache() clears it + the underlying YAML mtime cache,
|
|
// so after an admin save every worker re-parses on next request.
|
|
// In dev we always re-parse so hot-reload works.
|
|
if (_config && process.env.NODE_ENV === 'production') return _config;
|
|
|
|
const apiUrl = env.IMMICH_API_URL;
|
|
const apiKey = env.IMMICH_API_KEY;
|
|
const authSecret = resolveAuthSecret();
|
|
|
|
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
|
|
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
|
|
|
|
if (!gallery || !apiKey || !apiUrl) {
|
|
// Return dummy config if gallery.yaml is missing
|
|
_config = {
|
|
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
|
authSecret,
|
|
albums: [],
|
|
standaloneAlbums: [],
|
|
subpages: [],
|
|
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
|
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
|
lang: 'en',
|
|
seo: {
|
|
title: 'Setup Required',
|
|
description: 'Please configure Immich Folio',
|
|
noIndex: true,
|
|
noFollow: true,
|
|
},
|
|
heroImages: [],
|
|
exifOnHover: true,
|
|
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
|
|
theme: resolveTheme('studio'),
|
|
footer: null,
|
|
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
|
map: false,
|
|
transitions: false,
|
|
albumOverrides: {},
|
|
albumDescriptions: {},
|
|
albumPasswords: {},
|
|
albumHeroImages: {},
|
|
cacheTtl: env.CACHE_TTL * 1000,
|
|
rateLimitRpm: env.RATE_LIMIT_RPM,
|
|
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
|
|
needsSetup: true,
|
|
};
|
|
return _config;
|
|
}
|
|
|
|
const theme = resolveTheme(settings.theme);
|
|
|
|
const albumOverrides: Record<string, string> = {};
|
|
const albumDescriptions: Record<string, string> = {};
|
|
const albumPasswords: Record<string, string> = {};
|
|
const albumHeroImages: Record<string, string> = {};
|
|
|
|
function processAlbumEntry(
|
|
entry:
|
|
| string
|
|
| Record<string, string | { title: string; description?: string; password?: string; heroImage?: string }>,
|
|
context: string,
|
|
): string {
|
|
if (typeof entry === 'string') {
|
|
return validateUuid(entry, context);
|
|
}
|
|
const [uuid, value] = Object.entries(entry)[0];
|
|
const validatedUuid = validateUuid(uuid, context);
|
|
|
|
if (typeof value === 'string') {
|
|
albumOverrides[validatedUuid] = value;
|
|
} else {
|
|
if (value.title) albumOverrides[validatedUuid] = value.title;
|
|
if (value.description) albumDescriptions[validatedUuid] = value.description;
|
|
if (value.password) albumPasswords[validatedUuid] = value.password;
|
|
if (value.heroImage) albumHeroImages[validatedUuid] = value.heroImage;
|
|
}
|
|
return validatedUuid;
|
|
}
|
|
|
|
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
|
|
processAlbumEntry(entry, 'gallery.yaml albums'),
|
|
);
|
|
if (
|
|
standaloneAlbumIds.length === 0 &&
|
|
(!gallery.subpages ||
|
|
(Array.isArray(gallery.subpages)
|
|
? gallery.subpages.length === 0
|
|
: Object.keys(gallery.subpages).length === 0))
|
|
) {
|
|
throw new Error('gallery.yaml must define at least one album or subpage');
|
|
}
|
|
|
|
let subpages: SubpageConfig[] = [];
|
|
|
|
if (Array.isArray(gallery.subpages)) {
|
|
subpages = gallery.subpages.map((sp) => {
|
|
if (!sp.name) {
|
|
throw new Error(`Subpage "(unnamed)" must have a name`);
|
|
}
|
|
|
|
// Parse sections if present
|
|
let sections: SubpageSectionConfig[] | undefined;
|
|
let albumIds: string[];
|
|
|
|
if (sp.sections && sp.sections.length > 0) {
|
|
sections = sp.sections.map((sec) => ({
|
|
title: sec.title,
|
|
slug: slugify(sec.title),
|
|
description: sec.description,
|
|
albumIds: sec.albums.map((entry) =>
|
|
processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
|
|
),
|
|
}));
|
|
// flat union of all section albums
|
|
albumIds = sections.flatMap((s) => s.albumIds);
|
|
// Also include any top-level albums (outside sections)
|
|
const topLevel = (sp.albums ?? []).map((entry) =>
|
|
processAlbumEntry(entry, `subpage "${sp.name}"`),
|
|
);
|
|
albumIds = [...topLevel, ...albumIds];
|
|
} else {
|
|
const albums = sp.albums ?? [];
|
|
if (albums.length === 0) {
|
|
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
|
|
}
|
|
albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
|
|
}
|
|
|
|
return {
|
|
name: sp.name,
|
|
slug: slugify(sp.name),
|
|
title: sp.title,
|
|
subtitle: sp.subtitle,
|
|
albumIds,
|
|
sections,
|
|
password: sp.password,
|
|
...buildSubpageGrid(sp.grid),
|
|
};
|
|
});
|
|
} else if (gallery.subpages) {
|
|
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
|
|
if (Array.isArray(value)) {
|
|
return {
|
|
name,
|
|
slug: slugify(name),
|
|
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
|
};
|
|
}
|
|
const sp = value as SubpageObjectValue;
|
|
const albumEntries = sp.albums || [];
|
|
return {
|
|
name,
|
|
slug: slugify(name),
|
|
title: sp.title,
|
|
subtitle: sp.subtitle,
|
|
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
|
password: sp.password,
|
|
...buildSubpageGrid(sp.grid),
|
|
};
|
|
});
|
|
}
|
|
|
|
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
|
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
|
|
|
_config = {
|
|
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
|
authSecret,
|
|
albums: allAlbumIds,
|
|
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
|
subpages,
|
|
siteTitle: settings.title ?? env.SITE_TITLE,
|
|
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
|
lang: settings.lang ?? 'en',
|
|
seo: {
|
|
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
|
|
description:
|
|
settings.seo?.description ||
|
|
settings.subtitle ||
|
|
env.SITE_SUBTITLE ||
|
|
'A curated photography portfolio',
|
|
noIndex: settings.seo?.noIndex === true,
|
|
noFollow: settings.seo?.noFollow === true,
|
|
},
|
|
heroImages: gallery.hero
|
|
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
|
validateUuid(id, 'gallery.yaml hero'),
|
|
)
|
|
: [],
|
|
exifOnHover: settings.exifOnHover !== false,
|
|
grid: {
|
|
columns: settings.grid?.columns ?? 3,
|
|
gap: settings.grid?.gap ?? 12,
|
|
aspectRatio: settings.grid?.aspectRatio ?? '1',
|
|
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
|
|
? settings.grid!.layout
|
|
: 'masonry') as GridConfig['layout'],
|
|
},
|
|
theme,
|
|
footer: settings.footer
|
|
? {
|
|
name: settings.footer.name,
|
|
instagram: settings.footer.instagram,
|
|
email: settings.footer.email,
|
|
website: settings.footer.website,
|
|
}
|
|
: null,
|
|
legal: {
|
|
enabled: settings.legal?.enabled === true,
|
|
name: settings.legal?.name || '',
|
|
address: settings.legal?.address || '',
|
|
zipCity: settings.legal?.zipCity || '',
|
|
country: settings.legal?.country || '',
|
|
email: settings.legal?.email,
|
|
phone: settings.legal?.phone,
|
|
taxId: settings.legal?.taxId,
|
|
vatId: settings.legal?.vatId,
|
|
extraInfo: settings.legal?.extraInfo,
|
|
},
|
|
map: settings.map === true,
|
|
transitions: settings.transitions !== false,
|
|
albumOverrides,
|
|
albumDescriptions,
|
|
albumPasswords,
|
|
albumHeroImages,
|
|
cacheTtl: env.CACHE_TTL * 1000,
|
|
rateLimitRpm: env.RATE_LIMIT_RPM,
|
|
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
|
|
needsSetup: false,
|
|
};
|
|
return _config;
|
|
}
|