mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-12 02:22:17 +00:00
Merge commit from fork
Media.getMedia() loads the requests and issues relations for GET /movie/:id and GET /tv/:id, and the User relations hanging off them are eager, so every authenticated user could read any other user's email and plexId out of mediaInfo. The same payload carried full issue contents, including comment bodies, to viewers who are refused GET /issue. Media.filter() now strips the nested users and withholds the issues from anyone lacking the perms that the endpoing requires, and drops the issue fields nothing reads. Reported as a security advisory, GHSA-xc6j-6p6h-p799.
This commit is contained in:
@@ -12,6 +12,7 @@ import type { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
|
||||
@@ -425,6 +426,38 @@ class Media {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public filter(user?: User): Media {
|
||||
const canViewIssues =
|
||||
user?.hasPermission(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{ type: 'or' }
|
||||
) ?? false;
|
||||
|
||||
return {
|
||||
...this,
|
||||
requests: (this.requests ?? []).map((request) => ({
|
||||
...request,
|
||||
requestedBy: request.requestedBy?.filter(),
|
||||
modifiedBy: request.modifiedBy?.filter(),
|
||||
})),
|
||||
// the detail pages call issues.filter() without a null check
|
||||
issues: canViewIssues
|
||||
? (this.issues ?? []).map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ comments, problemSeason, problemEpisode, ...issue }) => ({
|
||||
...issue,
|
||||
createdBy: issue.createdBy?.filter(),
|
||||
modifiedBy: issue.modifiedBy?.filter(),
|
||||
})
|
||||
)
|
||||
: [],
|
||||
} as Media;
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, describe, it } from 'node:test';
|
||||
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { IssueType } from '@server/constants/issue';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Issue from '@server/entity/Issue';
|
||||
import IssueComment from '@server/entity/IssueComment';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import request from 'supertest';
|
||||
import authRoutes from './auth';
|
||||
import movieRoutes from './movie';
|
||||
import tvRoutes from './tv';
|
||||
|
||||
const OWNER_EMAIL = '[email protected]';
|
||||
const OWNER_PLEX_ID = 918273;
|
||||
const COMMENT_BODY = 'Only visible to issue viewers';
|
||||
|
||||
Object.defineProperty(TheMovieDb.prototype, 'getMovie', {
|
||||
get() {
|
||||
return async ({ movieId }: { movieId: number }) =>
|
||||
({
|
||||
id: movieId,
|
||||
genres: [],
|
||||
original_language: 'en',
|
||||
overview: 'overview',
|
||||
keywords: { keywords: [] },
|
||||
external_ids: {},
|
||||
release_dates: { results: [] },
|
||||
credits: { cast: [], crew: [] },
|
||||
production_companies: [],
|
||||
production_countries: [],
|
||||
spoken_languages: [],
|
||||
videos: { results: [] },
|
||||
}) as unknown as TmdbMovieDetails;
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
|
||||
get() {
|
||||
return async ({ tvId }: { tvId: number }) =>
|
||||
({
|
||||
id: tvId,
|
||||
genres: [],
|
||||
original_language: 'en',
|
||||
overview: 'overview',
|
||||
keywords: { results: [] },
|
||||
external_ids: {},
|
||||
content_ratings: { results: [] },
|
||||
credits: { cast: [], crew: [] },
|
||||
aggregate_credits: { cast: [], crew: [] },
|
||||
seasons: [],
|
||||
networks: [],
|
||||
production_companies: [],
|
||||
production_countries: [],
|
||||
spoken_languages: [],
|
||||
origin_country: [],
|
||||
episode_run_time: [],
|
||||
created_by: [],
|
||||
videos: { results: [] },
|
||||
}) as unknown as TmdbTvDetails;
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let app: Express;
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
secret: 'test-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
})
|
||||
);
|
||||
app.use(checkUser);
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/movie', isAuthenticated(), movieRoutes);
|
||||
app.use('/tv', isAuthenticated(), tvRoutes);
|
||||
app.use(
|
||||
(
|
||||
err: { status?: number; message?: string },
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
res
|
||||
.status(err.status ?? 500)
|
||||
.json({ status: err.status ?? 500, message: err.message });
|
||||
}
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
setupTestDb();
|
||||
|
||||
async function loginAs(email: string, password: string) {
|
||||
const settings = getSettings();
|
||||
const priorLocalLogin = settings.main.localLogin;
|
||||
settings.main.localLogin = true;
|
||||
|
||||
try {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/auth/local').send({ email, password });
|
||||
assert.strictEqual(res.status, 200);
|
||||
return agent;
|
||||
} finally {
|
||||
settings.main.localLogin = priorLocalLogin;
|
||||
}
|
||||
}
|
||||
|
||||
async function setViewerPermissions(permissions: number) {
|
||||
const userRepository = getRepository(User);
|
||||
const viewer = await userRepository.findOneOrFail({
|
||||
where: { email: '[email protected]' },
|
||||
});
|
||||
viewer.permissions = permissions;
|
||||
await userRepository.save(viewer);
|
||||
return viewer;
|
||||
}
|
||||
|
||||
async function seedMedia(mediaType: MediaType, tmdbId: number) {
|
||||
const userRepository = getRepository(User);
|
||||
const owner = await userRepository.findOneOrFail({ where: { id: 1 } });
|
||||
owner.email = OWNER_EMAIL;
|
||||
owner.plexId = OWNER_PLEX_ID;
|
||||
await userRepository.save(owner);
|
||||
|
||||
const media = await getRepository(Media).save(
|
||||
new Media({
|
||||
mediaType,
|
||||
tmdbId,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
|
||||
await getRepository(MediaRequest).save(
|
||||
new MediaRequest({
|
||||
type: mediaType,
|
||||
media,
|
||||
requestedBy: owner,
|
||||
modifiedBy: owner,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
is4k: false,
|
||||
})
|
||||
);
|
||||
|
||||
const issue = await getRepository(Issue).save(
|
||||
new Issue({
|
||||
issueType: IssueType.VIDEO,
|
||||
media,
|
||||
createdBy: owner,
|
||||
problemSeason: 2,
|
||||
problemEpisode: 5,
|
||||
})
|
||||
);
|
||||
|
||||
await getRepository(IssueComment).save(
|
||||
new IssueComment({ user: owner, issue, message: COMMENT_BODY })
|
||||
);
|
||||
|
||||
return { media, owner };
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/movie', tmdbId: 12345, mediaType: MediaType.MOVIE },
|
||||
{ path: '/tv', tmdbId: 67890, mediaType: MediaType.TV },
|
||||
];
|
||||
|
||||
for (const { path, tmdbId, mediaType } of routes) {
|
||||
describe(`GET ${path}/:id mediaInfo`, () => {
|
||||
it('omits the requesting user’s email and plexId', async () => {
|
||||
await seedMedia(mediaType, tmdbId);
|
||||
await setViewerPermissions(Permission.REQUEST);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.get(`${path}/${tmdbId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.mediaInfo.requests.length, 1);
|
||||
|
||||
const serialized = JSON.stringify(res.body);
|
||||
assert.ok(!serialized.includes(OWNER_EMAIL), 'email was serialized');
|
||||
assert.ok(
|
||||
!serialized.includes(String(OWNER_PLEX_ID)),
|
||||
'plexId was serialized'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the request fields the UI renders', async () => {
|
||||
await seedMedia(mediaType, tmdbId);
|
||||
await setViewerPermissions(Permission.REQUEST);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.get(`${path}/${tmdbId}`);
|
||||
|
||||
const { requestedBy } = res.body.mediaInfo.requests[0];
|
||||
assert.strictEqual(requestedBy.id, 1);
|
||||
assert.strictEqual(typeof requestedBy.displayName, 'string');
|
||||
assert.ok('avatar' in requestedBy);
|
||||
});
|
||||
|
||||
it('hides issues from a viewer without an issue permission', async () => {
|
||||
await seedMedia(mediaType, tmdbId);
|
||||
await setViewerPermissions(Permission.REQUEST);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.get(`${path}/${tmdbId}`);
|
||||
|
||||
assert.deepStrictEqual(res.body.mediaInfo.issues, []);
|
||||
assert.ok(!JSON.stringify(res.body).includes(COMMENT_BODY));
|
||||
});
|
||||
|
||||
for (const permission of [
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
]) {
|
||||
it(`returns issues without contents to a viewer with ${Permission[permission]}`, async () => {
|
||||
await seedMedia(mediaType, tmdbId);
|
||||
await setViewerPermissions(Permission.REQUEST | permission);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.get(`${path}/${tmdbId}`);
|
||||
|
||||
assert.strictEqual(res.body.mediaInfo.issues.length, 1);
|
||||
|
||||
const [issue] = res.body.mediaInfo.issues;
|
||||
assert.strictEqual(issue.issueType, IssueType.VIDEO);
|
||||
assert.ok(!('comments' in issue), 'comments were serialized');
|
||||
assert.ok(!('problemSeason' in issue), 'problemSeason was serialized');
|
||||
assert.ok(
|
||||
!('problemEpisode' in issue),
|
||||
'problemEpisode was serialized'
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(res.body);
|
||||
assert.ok(!serialized.includes(COMMENT_BODY));
|
||||
assert.ok(!serialized.includes(OWNER_EMAIL));
|
||||
assert.ok(!serialized.includes(String(OWNER_PLEX_ID)));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -34,7 +34,11 @@ movieRoutes.get('/:id', async (req, res, next) => {
|
||||
},
|
||||
});
|
||||
|
||||
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
|
||||
const data = mapMovieDetails(
|
||||
tmdbMovie,
|
||||
media?.filter(req.user),
|
||||
onUserWatchlist
|
||||
);
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
},
|
||||
});
|
||||
|
||||
const data = mapTvDetails(tv, media, onUserWatchlist);
|
||||
const data = mapTvDetails(tv, media?.filter(req.user), onUserWatchlist);
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
|
||||
Reference in New Issue
Block a user