fix: mitigate SSRF vulnerabilities
This commit is contained in:
@@ -150,42 +150,84 @@ class CoverArtArchive extends ExternalAPI {
|
||||
): Promise<Record<string, string | null>> {
|
||||
if (!ids.length) return {};
|
||||
|
||||
const validIds = ids.filter(
|
||||
(id) =>
|
||||
typeof id === 'string' &&
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
if (!validIds.length) return {};
|
||||
|
||||
const metadataRepository = getRepository(MetadataAlbum);
|
||||
const existingMetadata = await metadataRepository.find({
|
||||
where: { mbAlbumId: In(ids) },
|
||||
where: { mbAlbumId: In(validIds) },
|
||||
select: ['mbAlbumId', 'caaUrl', 'updatedAt'],
|
||||
});
|
||||
|
||||
const metadataMap = new Map(
|
||||
existingMetadata.map((metadata) => [metadata.mbAlbumId, metadata])
|
||||
);
|
||||
|
||||
const results: Record<string, string | null> = {};
|
||||
const idsToFetch: string[] = [];
|
||||
|
||||
ids.forEach((id) => {
|
||||
const metadata = existingMetadata.find((m) => m.mbAlbumId === id);
|
||||
for (const id of validIds) {
|
||||
const metadata = metadataMap.get(id);
|
||||
|
||||
if (metadata?.caaUrl) {
|
||||
results[id] = metadata.caaUrl;
|
||||
Object.defineProperty(results, id, {
|
||||
value: metadata.caaUrl,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
} else if (metadata && !this.isMetadataStale(metadata)) {
|
||||
results[id] = null;
|
||||
Object.defineProperty(results, id, {
|
||||
value: null,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
idsToFetch.push(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (idsToFetch.length > 0) {
|
||||
const batchPromises = idsToFetch.map((id) =>
|
||||
this.fetchCoverArt(id)
|
||||
.then((response) => {
|
||||
const frontImage = response.images.find((img) => img.front);
|
||||
results[id] = frontImage?.thumbnails?.[250] || null;
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
results[id] = null;
|
||||
return false;
|
||||
})
|
||||
);
|
||||
try {
|
||||
const batchPromises = idsToFetch.map((id) =>
|
||||
this.fetchCoverArt(id)
|
||||
.then((response) => {
|
||||
const frontImage = response.images.find((img) => img.front);
|
||||
Object.defineProperty(results, id, {
|
||||
value: frontImage?.thumbnails?.[250] || null,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to fetch cover art', {
|
||||
label: 'CoverArtArchive',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
Object.defineProperty(results, id, {
|
||||
value: null,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
await Promise.all(batchPromises);
|
||||
} catch (error) {
|
||||
logger.error('Failed to process cover art requests', {
|
||||
label: 'CoverArtArchive',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -98,16 +98,27 @@ class MusicBrainz extends ExternalAPI {
|
||||
artistMbid: string;
|
||||
language?: string;
|
||||
}): Promise<{ title: string; url: string; content: string } | null> {
|
||||
if (
|
||||
!artistMbid ||
|
||||
typeof artistMbid !== 'string' ||
|
||||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
artistMbid
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid MusicBrainz artist ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': language,
|
||||
},
|
||||
}
|
||||
);
|
||||
const safeUrl = `https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`;
|
||||
|
||||
const response = await fetch(safeUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': language,
|
||||
'User-Agent':
|
||||
'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
@@ -115,7 +126,7 @@ class MusicBrainz extends ExternalAPI {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.wikipediaExtract || !data.wikipediaExtract.content) {
|
||||
throw new Error('No Wikipedia extract found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanContent = purify.sanitize(data.wikipediaExtract.content, {
|
||||
@@ -129,7 +140,11 @@ class MusicBrainz extends ExternalAPI {
|
||||
content: cleanContent.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Error fetching Wikipedia extract: ${error.message}`);
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to fetch Wikipedia extract: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +360,30 @@ const ArtistDetails = () => {
|
||||
async (albumType: string): Promise<void> => {
|
||||
if (!artistId) return;
|
||||
|
||||
if (
|
||||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
artistId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validAlbumTypes = [
|
||||
'Album',
|
||||
'EP',
|
||||
'Single',
|
||||
'Live',
|
||||
'Compilation',
|
||||
'Remix',
|
||||
'Soundtrack',
|
||||
'Broadcast',
|
||||
'Demo',
|
||||
'Other',
|
||||
];
|
||||
if (!validAlbumTypes.includes(albumType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAlbumTypes((prev) => ({
|
||||
...prev,
|
||||
[albumType]: {
|
||||
@@ -369,10 +393,11 @@ const ArtistDetails = () => {
|
||||
}));
|
||||
|
||||
try {
|
||||
const pageSize = Math.min(data?.typeCounts?.[albumType] || 100, 1000);
|
||||
const response = await fetch(
|
||||
`/api/v1/artist/${artistId}?albumType=${albumType}&pageSize=${
|
||||
data?.typeCounts?.[albumType] || 100
|
||||
}`
|
||||
`/api/v1/artist/${artistId}?albumType=${encodeURIComponent(
|
||||
albumType
|
||||
)}&pageSize=${pageSize}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -392,6 +392,31 @@ const PersonDetails = () => {
|
||||
async (albumType: string): Promise<void> => {
|
||||
if (!personId) return;
|
||||
|
||||
const parsedPersonId = parseInt(personId, 10);
|
||||
if (
|
||||
isNaN(parsedPersonId) ||
|
||||
parsedPersonId <= 0 ||
|
||||
parsedPersonId.toString() !== personId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validAlbumTypes = [
|
||||
'Album',
|
||||
'EP',
|
||||
'Single',
|
||||
'Live',
|
||||
'Compilation',
|
||||
'Remix',
|
||||
'Soundtrack',
|
||||
'Broadcast',
|
||||
'Demo',
|
||||
'Other',
|
||||
];
|
||||
if (!validAlbumTypes.includes(albumType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAlbumTypes((prev) => ({
|
||||
...prev,
|
||||
[albumType]: {
|
||||
@@ -401,9 +426,14 @@ const PersonDetails = () => {
|
||||
}));
|
||||
|
||||
try {
|
||||
const pageSize = data?.artist?.typeCounts?.[albumType] || 100;
|
||||
const pageSize = Math.min(
|
||||
data?.artist?.typeCounts?.[albumType] || 100,
|
||||
1000
|
||||
);
|
||||
const response = await fetch(
|
||||
`/api/v1/person/${personId}?albumType=${albumType}&pageSize=${pageSize}`
|
||||
`/api/v1/person/${parsedPersonId}?albumType=${encodeURIComponent(
|
||||
albumType
|
||||
)}&pageSize=${pageSize}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -434,7 +464,7 @@ const PersonDetails = () => {
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setAlbumTypes((prev) => ({
|
||||
...prev,
|
||||
[albumType]: {
|
||||
|
||||
Reference in New Issue
Block a user