From b7dc1c414b19af319271c39562cb2613281e6a40 Mon Sep 17 00:00:00 2001 From: kushiemoon-dev Date: Sun, 12 Jul 2026 19:31:35 +0200 Subject: [PATCH 1/3] Fix quota bypass when editing a pending request's seasons TvRequestModal, MovieRequestModal, and CollectionRequestModal all skip fetching the requester's quota whenever requestOverrides.user.id is set and the acting user lacks MANAGE_USERS. Editing an existing request always populates requestOverrides.user with the request's original owner (regardless of the acting user's permissions), so this condition was true for essentially every edit - meaning quota was silently never fetched, and the UI never disabled submission once the user's season limit was reached. A user at their quota could open a pending request and add further seasons past their limit. This also affected the new admin "Bypass User Quota" checkbox added in #2026: its visibility depends on quota.tv.limit / quota.movie.limit being loaded, so it silently failed to appear whenever this same condition suppressed the fetch. The fix removes the permission-gated condition and always fetches quota for the effective target user. This doesn't weaken anything: User.getQuota() already returns an unrestricted quota server-side for users with MANAGE_USERS, so admins editing their own request are unaffected; the change only ensures quota is actually loaded (and enforced) for the common case of a regular user editing their own request. The corresponding server-side gap is fixed as well: PUT /request/:id recomputed which seasons were being added but never checked them against the requester's quota, so the limit could already be bypassed directly via the API regardless of the frontend. It now runs the same quota check used on request creation, including support for the explicit ignoreQuota flag from #2026 (an admin must opt in via ignoreQuota: true - having MANAGE_REQUESTS alone no longer grants a silent bypass), and persists request.ignoreQuota so the seasons added this way are correctly excluded from future quota calculations. Added test coverage in request.test.ts for: a regular user exceeding their quota on edit, an admin edit without the explicit flag (still enforced), a non-admin attempting to set the flag themselves (rejected), and an admin using the flag to legitimately bypass quota. Fixes #633 --- server/routes/request.test.ts | 138 ++++++++++++++++++ server/routes/request.ts | 33 +++++ .../RequestModal/CollectionRequestModal.tsx | 5 +- .../RequestModal/MovieRequestModal.tsx | 5 +- .../RequestModal/TvRequestModal.tsx | 5 +- 5 files changed, 174 insertions(+), 12 deletions(-) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index d90f6d3b0c..1f50ea2b47 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -9,6 +9,7 @@ import { import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; @@ -213,6 +214,143 @@ describe('PUT /request/:requestId (movie)', () => { }); }); +describe('PUT /request/:requestId (tv, season quota enforcement)', () => { + async function seedTvRequestAtQuota(tmdbId: number) { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + friend.tvQuotaLimit = 2; + friend.tvQuotaDays = undefined; + await userRepo.save(friend); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const created = await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media, + requestedBy: friend, + is4k: false, + seasons: [1, 2].map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); + + return requestRepo.findOneOrFail({ + where: { id: created.id }, + relations: { requestedBy: true, seasons: true }, + }); + } + + it('rejects adding a season beyond the requester quota', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90001); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); + + it('rejects an admin edit beyond quota without the explicit ignoreQuota flag', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90002); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); + + it('rejects a non-admin attempting to set ignoreQuota themselves', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90003); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + ignoreQuota: true, + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); + + it('allows an admin to bypass quota via the explicit ignoreQuota flag', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90004); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + ignoreQuota: true, + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2, 3] + ); + assert.strictEqual(saved.ignoreQuota, true); + }); +}); + describe('POST /request/:requestId/:status', () => { const cases = [ { action: 'approve', expected: MediaRequestStatus.APPROVED }, diff --git a/server/routes/request.ts b/server/routes/request.ts index fafa90692e..2515d93742 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -568,6 +568,32 @@ requestRoutes.put<{ requestId: string }>( (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) ); + if (newSeasons.length > 0) { + const quota = await requestUser.getQuota(); + const canBypassQuota = !!req.user?.hasPermission( + Permission.MANAGE_REQUESTS + ); + const ignoreQuota = + req.body.ignoreQuota === true && + canBypassQuota && + (quota.tv.limit ?? 0) > 0; + + if (!ignoreQuota) { + if (req.body.ignoreQuota && !canBypassQuota) { + throw new RequestPermissionError( + 'You do not have permission to bypass user quota limits.' + ); + } else if ( + quota.tv.limit && + newSeasons.length > (quota.tv.remaining ?? 0) + ) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + } else { + request.ignoreQuota = true; + } + } + request.seasons = request.seasons.filter((rs) => filteredSeasons.includes(rs.seasonNumber) ); @@ -593,6 +619,13 @@ requestRoutes.put<{ requestId: string }>( return res.status(200).json(request); } catch (e) { + if ( + e instanceof QuotaRestrictedError || + e instanceof RequestPermissionError + ) { + return next({ status: 403, message: e.message }); + } + next({ status: 500, message: e.message }); } } diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index f704b09c02..01c444351c 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -57,10 +57,7 @@ const CollectionRequestModal = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const { data: quota } = useSWR( - user && - (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) - ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` - : null + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null ); const currentlyRemaining = diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 9ee163317c..801f778120 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -64,10 +64,7 @@ const MovieRequestModal = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const { data: quota } = useSWR( - user && - (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) - ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` - : null + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null ); useEffect(() => { diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 2dfed160a3..e9e06c9424 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -89,10 +89,7 @@ const TvRequestModal = ({ }); const [tvdbId, setTvdbId] = useState(undefined); const { data: quota } = useSWR( - user && - (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) - ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` - : null + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null ); const currentlyRemaining = From 0f866d014181df04d35dcf78293d37da97bd42b4 Mon Sep 17 00:00:00 2001 From: kushiemoon-dev Date: Sun, 12 Jul 2026 20:43:48 +0200 Subject: [PATCH 2/3] fix: account for seasons removed from the same edit in quota check CodeRabbit flagged that newSeasons.length was compared against quota.tv.remaining without crediting back seasons the same edit drops, since getQuota() counts persisted rows before the save. A same-count season swap at full quota was rejected even though the net season total wouldn't change. --- server/routes/request.test.ts | 22 ++++++++++++++++++++++ server/routes/request.ts | 10 +++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 1f50ea2b47..369577a019 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -349,6 +349,28 @@ describe('PUT /request/:requestId (tv, season quota enforcement)', () => { ); assert.strictEqual(saved.ignoreQuota, true); }); + + it('allows swapping seasons at full quota when the count stays the same', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90005); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [3, 4], + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [3, 4] + ); + }); }); describe('POST /request/:requestId/:status', () => { diff --git a/server/routes/request.ts b/server/routes/request.ts index 2515d93742..161ff46fd3 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -568,6 +568,13 @@ requestRoutes.put<{ requestId: string }>( (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) ); + // Seasons dropped by this same edit are still counted in quota.tv.used + // (getQuota() reads the persisted rows before we save below), so they + // must be credited back before comparing against newSeasons. + const removedSeasonsCount = request.seasons.filter( + (rs) => !requestedSeasons.includes(rs.seasonNumber) + ).length; + if (newSeasons.length > 0) { const quota = await requestUser.getQuota(); const canBypassQuota = !!req.user?.hasPermission( @@ -585,7 +592,8 @@ requestRoutes.put<{ requestId: string }>( ); } else if ( quota.tv.limit && - newSeasons.length > (quota.tv.remaining ?? 0) + newSeasons.length > + (quota.tv.remaining ?? 0) + removedSeasonsCount ) { throw new QuotaRestrictedError('Series Quota exceeded.'); } From fc9ad6272c970171a039f8767852850982cb3bdc Mon Sep 17 00:00:00 2001 From: kushiemoon-dev Date: Mon, 20 Jul 2026 01:16:54 +0200 Subject: [PATCH 3/3] Fix quota credit-back for requests with ignoreQuota already persisted Seasons removed from an already-exempt request (ignoreQuota: true from creation or a prior authorized edit) were never counted in quota.tv.used, so crediting them back inflated the allowed threshold and let a subsequent non-bypass edit add seasons past the real limit. Also compare against quota.tv.used/limit directly instead of the clamped quota.tv.remaining, which under-restricts once usage already exceeds the limit. Adds a regression test covering the swap-past-quota scenario. --- server/routes/request.test.ts | 84 +++++++++++++++++++++++++++++++++++ server/routes/request.ts | 17 ++++--- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 369577a019..99fa5eb132 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -371,6 +371,90 @@ describe('PUT /request/:requestId (tv, season quota enforcement)', () => { [3, 4] ); }); + + it('rejects a non-bypass edit that swaps seasons on an ignoreQuota-exempt request past quota used elsewhere', async () => { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + friend.tvQuotaLimit = 2; + friend.tvQuotaDays = undefined; + await userRepo.save(friend); + + // A request already exempted from quota counting (ignoreQuota persisted + // from a prior authorized edit or from creation). + const exemptMedia = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 90006, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + const exemptRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media: exemptMedia, + requestedBy: friend, + is4k: false, + ignoreQuota: true, + seasons: [1, 2].map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); + + // A separate, normally-counted request that already consumes the full quota. + const countedMedia = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 90007, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media: countedMedia, + requestedBy: friend, + is4k: false, + seasons: [3, 4].map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${exemptRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 5], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: exemptRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); }); describe('POST /request/:requestId/:status', () => { diff --git a/server/routes/request.ts b/server/routes/request.ts index 161ff46fd3..bf3c2cadbd 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -570,10 +570,15 @@ requestRoutes.put<{ requestId: string }>( // Seasons dropped by this same edit are still counted in quota.tv.used // (getQuota() reads the persisted rows before we save below), so they - // must be credited back before comparing against newSeasons. - const removedSeasonsCount = request.seasons.filter( - (rs) => !requestedSeasons.includes(rs.seasonNumber) - ).length; + // must be credited back before comparing against newSeasons. But if + // the request already has ignoreQuota persisted, those seasons were + // never counted in quota.tv.used in the first place, so crediting + // them back would inflate the allowed threshold. + const removedSeasonsCount = request.ignoreQuota + ? 0 + : request.seasons.filter( + (rs) => !requestedSeasons.includes(rs.seasonNumber) + ).length; if (newSeasons.length > 0) { const quota = await requestUser.getQuota(); @@ -592,8 +597,8 @@ requestRoutes.put<{ requestId: string }>( ); } else if ( quota.tv.limit && - newSeasons.length > - (quota.tv.remaining ?? 0) + removedSeasonsCount + quota.tv.used - removedSeasonsCount + newSeasons.length > + quota.tv.limit ) { throw new QuotaRestrictedError('Series Quota exceeded.'); }