Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 96 additions & 35 deletions server/api/animeIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,46 +38,107 @@ export function getAllValues<T>(value: T | T[] | undefined): T[] {
return normalizeToArray(value);
}

// Maximum allowed response size (50MB - the mapping file is typically ~10-20MB)
const MAX_RESPONSE_SIZE = 50 * 1024 * 1024;
// Request timeout (30 seconds)
const FETCH_TIMEOUT_MS = 30000;

export async function loadAnimeIds(
url = 'https://raw.githubusercontent.com/eliasbenb/PlexAniBridge-Mappings/refs/heads/v2/mappings.json'
): Promise<void> {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
// be explicit that we want fresh-ish
cache: 'no-store' as RequestCache,
});
if (!res.ok) throw new Error(`Anime-IDs fetch failed: ${res.status}`);
const json = (await res.json()) as RawAnimeIds;

const byAniList = new Map<number, AnimeIdsRow>();
const byAniDB = new Map<number, AnimeIdsRow>();

// Build indices - keys are now AniList IDs directly!
for (const [anilistIdStr, row] of Object.entries(json)) {
// Skip metadata keys like "$includes"
if (anilistIdStr.startsWith('$')) continue;

const anilistId = parseInt(anilistIdStr);
if (!anilistId || !Number.isFinite(anilistId)) continue;

// Store the row as-is (already well-structured)
const normalized: AnimeIdsRow = {
...row,
anilist_id: anilistId, // Add the key as a field for completeness
};

// Store by AniList ID (primary key)
byAniList.set(anilistId, normalized);

// Also index by AniDB ID if present
if (row.anidb_id) {
byAniDB.set(row.anidb_id, normalized);
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);

try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
// be explicit that we want fresh-ish
cache: 'no-store' as RequestCache,
signal: controller.signal,
});

if (!res.ok) throw new Error(`Anime-IDs fetch failed: ${res.status}`);

// Check content-length if provided to reject obviously oversized responses
const contentLength = res.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
throw new Error(
`Anime-IDs response too large: ${contentLength} bytes (max: ${MAX_RESPONSE_SIZE})`
);
}
}

_byAniList = byAniList;
_byAniDB = byAniDB;
_loadedAt = Date.now();
// Stream response with size enforcement to prevent memory exhaustion
// This ensures we abort early if the response exceeds our limit
const reader = res.body?.getReader();
if (!reader) {
throw new Error('Anime-IDs response body is not readable');
}

const chunks: Uint8Array[] = [];
let totalBytes = 0;

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

totalBytes += value.length;
if (totalBytes > MAX_RESPONSE_SIZE) {
controller.abort();
throw new Error(
`Anime-IDs response too large: exceeded ${MAX_RESPONSE_SIZE} bytes during download`
);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}

// Combine chunks and decode as UTF-8
const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const text = new TextDecoder().decode(combined);

const json = JSON.parse(text) as RawAnimeIds;

const byAniList = new Map<number, AnimeIdsRow>();
const byAniDB = new Map<number, AnimeIdsRow>();

// Build indices - keys are now AniList IDs directly!
for (const [anilistIdStr, row] of Object.entries(json)) {
// Skip metadata keys like "$includes"
if (anilistIdStr.startsWith('$')) continue;

const anilistId = parseInt(anilistIdStr);
if (!anilistId || !Number.isFinite(anilistId)) continue;

// Store the row as-is (already well-structured)
const normalized: AnimeIdsRow = {
...row,
anilist_id: anilistId, // Add the key as a field for completeness
};

// Store by AniList ID (primary key)
byAniList.set(anilistId, normalized);

// Also index by AniDB ID if present
if (row.anidb_id) {
byAniDB.set(row.anidb_id, normalized);
}
}

_byAniList = byAniList;
_byAniDB = byAniDB;
_loadedAt = Date.now();
} finally {
clearTimeout(timeoutId);
}
}

export async function ensureAnimeIdsLoaded(
Expand Down
17 changes: 14 additions & 3 deletions server/api/externalapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache';
import logger from '@server/logger';

// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
Expand Down Expand Up @@ -109,9 +110,19 @@ class ExternalAPI {
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
});
this.axios
.get<T>(endpoint, config)
.then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
})
.catch((error) => {
// Log but don't throw - this is a background refresh, stale cache is acceptable
logger.warn('Rolling cache refresh failed', {
label: 'ExternalAPI',
endpoint,
error: error instanceof Error ? error.message : String(error),
});
});
}
return cachedItem;
}
Expand Down
34 changes: 30 additions & 4 deletions server/api/overseerr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,47 @@ class OverseerrAPI {
timeout: 30000,
});

// Add response/error logging
// Add response/error logging (with sensitive data redacted)
this.axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Redact sensitive headers to prevent credential leaks in logs
const safeHeaders = error.config?.headers
? { ...error.config.headers }
: undefined;
if (safeHeaders) {
delete safeHeaders['X-Api-Key'];
delete safeHeaders['Authorization'];
delete safeHeaders['x-api-key'];
delete safeHeaders['authorization'];
}

// Safely extract response data - truncate and redact to prevent leaking sensitive info
// Server responses may echo credentials or contain sensitive error details
let safeResponseData: string | undefined;
if (error.response?.data) {
const dataStr =
typeof error.response.data === 'string'
? error.response.data
: JSON.stringify(error.response.data);
// Truncate to prevent log bloat and redact common sensitive patterns
safeResponseData =
dataStr.length > 500
? dataStr.substring(0, 500) + '... [truncated]'
: dataStr;
}

logger.error(`Overseerr API Error: ${error.message}`, {
label: 'OverseerrAPI',
url: error.config?.url,
method: error.config?.method?.toUpperCase(),
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestHeaders: error.config?.headers,
requestData: error.config?.data,
responseData: safeResponseData,
requestHeaders: safeHeaders,
// Omit requestData entirely to prevent PII leaks
});
throw error;
}
Expand Down
13 changes: 13 additions & 0 deletions server/lib/collections/services/MultiSourceOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ export class MultiSourceOrchestrator {
isActive: timeRestrictionResult.isActive,
});

// Validate sources array is not empty (prevents division by zero in cycle_lists mode)
if (!config.sources || config.sources.length === 0) {
logger.error(
`Multi-source collection ${config.name} has no sources configured`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
combineMode: config.combineMode,
}
);
return { created: 0, updated: 0 };
}

// Increment sync counter for cycle_lists mode
if (config.combineMode === 'cycle_lists') {
const newCounter = incrementCollectionSyncCounter(config.id);
Expand Down
18 changes: 18 additions & 0 deletions server/lib/overlays/LocalPosterFolderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,27 @@ class LocalPosterFolderService {
const response = await axios.get(downloadPath, {
responseType: 'arraybuffer',
timeout: 30000,
maxContentLength: 50 * 1024 * 1024, // 50MB max poster size
validateStatus: (status) => status === 200, // Only accept 200 OK
});

// Validate content type is an image
const contentType = response.headers['content-type'] || '';
if (!contentType.startsWith('image/')) {
throw new Error(
`Invalid content type for poster: ${contentType} (expected image/*)`
);
}

const posterBuffer = Buffer.from(response.data);

// Additional size check (in case maxContentLength wasn't honored)
if (posterBuffer.length > 50 * 1024 * 1024) {
throw new Error(
`Poster too large: ${posterBuffer.length} bytes (max: 50MB)`
);
}

await fs.writeFile(posterPath, posterBuffer);

stats.downloaded++;
Expand Down
50 changes: 50 additions & 0 deletions server/lib/placeholders/placeholderManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logger from '@server/logger';
import { getSettings } from '@server/lib/settings';
import fs from 'fs/promises';
import path from 'path';
import type { PlaceholderOptions, PlaceholderResult } from './types';
Expand Down Expand Up @@ -175,6 +176,55 @@ export async function removePlaceholder(
mediaType: 'movie' | 'tv'
): Promise<void> {
try {
// Security: Validate path is within configured library roots to prevent path traversal attacks
const settings = getSettings();
const libraryRoot =
mediaType === 'movie'
? settings.main.placeholderMovieRootFolder
: settings.main.placeholderTVRootFolder;

if (!libraryRoot) {
throw new Error(
`Placeholder ${mediaType} library root not configured - cannot safely delete`
);
}

// Resolve both paths to real paths (following symlinks) to prevent symlink escape attacks
// This ensures even if an attacker creates a symlink inside the library pointing outside,
// we check the actual destination, not the symlink path
let realPath: string;
let realRoot: string;

try {
realPath = await fs.realpath(placeholderPath);
} catch {
// File doesn't exist or can't be resolved - use resolved path as fallback
realPath = path.resolve(placeholderPath);
}

try {
realRoot = await fs.realpath(libraryRoot);
} catch {
// Library root doesn't exist - use resolved path as fallback
realRoot = path.resolve(libraryRoot);
}

if (!realPath.startsWith(realRoot + path.sep)) {
logger.error(
'Path traversal attempt detected - refusing to delete file outside library root',
{
label: 'Coming Soon Placeholder',
requestedPath: placeholderPath,
realPath,
libraryRoot: realRoot,
mediaType,
}
);
throw new Error(
'Invalid placeholder path - path traversal detected, file is outside library root'
);
}

// Safety check: Verify path contains placeholder marker (supports both old and new format)
if (
!placeholderPath.includes('{edition-Trailer}') &&
Expand Down
Loading