From 1e0106cad8bf49a8f34d81435337f98e6d30bdd1 Mon Sep 17 00:00:00 2001 From: Frank Gonnello Date: Sun, 21 Dec 2025 20:47:06 -0500 Subject: [PATCH 1/2] fix: prevent admin auto-approval bypass for third-party tool integration This commit enables admin users to opt out of auto-approval, for cases like allowing third-party tools (Availarr, Ombi, etc.) to intercept and process media requests before they are automatically approved. Changes: - Modified hasPermission() to not auto-grant auto-approve permissions for admins - they must be explicitly enabled - Removed MANAGE_REQUESTS from auto-approval checks in MediaRequest.ts - Fixed watchlist sync typo: AUTO_APPROVE_TV -> AUTO_REQUEST_TV - Added ability for owner (user ID 1) to modify their own auto-approve permissions via the UI - Updated frontend UI to correctly reflect auto-approve status Files changed: - server/lib/permissions.ts: New isAutoApprovePermission() helper - server/entity/MediaRequest.ts: Remove MANAGE_REQUESTS from checks - server/lib/watchlistsync.ts: Fix permission typo - server/routes/user/usersettings.ts: Allow owner auto-approve changes - src/components/PermissionOption/index.tsx: UI editability for owner - src/components/RequestModal/*.tsx: Consistent hasAutoApprove checks --- server/entity/MediaRequest.ts | 13 ++-- server/lib/permissions.ts | 64 +++++++++++++++---- server/lib/watchlistsync.ts | 2 +- server/routes/user/usersettings.ts | 25 +++++++- src/components/PermissionOption/index.tsx | 13 ++-- .../RequestModal/CollectionRequestModal.tsx | 3 +- .../RequestModal/MovieRequestModal.tsx | 3 +- .../RequestModal/TvRequestModal.tsx | 3 +- 8 files changed, 97 insertions(+), 29 deletions(-) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index cdfa17c3a3..9750f138e8 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -336,7 +336,9 @@ export class MediaRequest { type: MediaType.MOVIE, media, requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request + // If the user has the "auto approve" permission, automatically approve the request + // Note: MANAGE_REQUESTS is intentionally excluded to allow third-party tools + // to intercept admin requests for additional processing status: user.hasPermission( [ requestBody.is4k @@ -345,7 +347,6 @@ export class MediaRequest { requestBody.is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, ], { type: 'or' } ) @@ -359,7 +360,6 @@ export class MediaRequest { requestBody.is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, ], { type: 'or' } ) @@ -446,7 +446,9 @@ export class MediaRequest { type: MediaType.TV, media, requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request + // If the user has the "auto approve" permission, automatically approve the request + // Note: MANAGE_REQUESTS is intentionally excluded to allow third-party tools + // to intercept admin requests for additional processing status: user.hasPermission( [ requestBody.is4k @@ -455,7 +457,6 @@ export class MediaRequest { requestBody.is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, ], { type: 'or' } ) @@ -469,7 +470,6 @@ export class MediaRequest { requestBody.is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, ], { type: 'or' } ) @@ -493,7 +493,6 @@ export class MediaRequest { requestBody.is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, ], { type: 'or' } ) diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index bc477169c0..5354b849f8 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -35,40 +35,80 @@ export interface PermissionCheckOptions { type: 'and' | 'or'; } +/** + * Checks if a permission is an auto-approve permission. + * Admin users should NOT automatically bypass these permissions, + * allowing third-party tools to intercept and process requests. + */ +function isAutoApprovePermission(perm: Permission): boolean { + return ( + perm === Permission.AUTO_APPROVE || + perm === Permission.AUTO_APPROVE_MOVIE || + perm === Permission.AUTO_APPROVE_TV || + perm === Permission.AUTO_APPROVE_4K || + perm === Permission.AUTO_APPROVE_4K_MOVIE || + perm === Permission.AUTO_APPROVE_4K_TV + ); +} + /** * Takes a Permission and the users permission value and determines * if the user has access to the permission provided. If the user has - * the admin permission, true will always be returned from this check! + * the admin permission, true will be returned UNLESS the permission + * being checked is an auto-approve permission (to allow third-party + * tools to intercept requests). * * @param permissions Single permission or array of permissions - * @param value users current permission value + * @param userPermissionValue users current permission value * @param options Extra options to control permission check behavior (mainly for arrays) */ export const hasPermission = ( permissions: Permission | Permission[], - value: number, + userPermissionValue: number, options: PermissionCheckOptions = { type: 'and' } ): boolean => { - let total = 0; + // Normalize permissions to an array + const requiredPermissions: Permission[] = Array.isArray(permissions) + ? permissions + : [permissions]; - // If we are not checking any permissions, bail out and return true - if (permissions === 0) { + // If we're not checking any permissions at all, return true + if (requiredPermissions.length === 0) { return true; } + // Handle array of permissions if (Array.isArray(permissions)) { - if (value & Permission.ADMIN) { + // Check if this array includes ANY auto-approve permission + const includesAutoApprove = requiredPermissions.some((perm) => + isAutoApprovePermission(perm) + ); + + // If there's NO auto-approve permission in the list, admin bypasses + if (!includesAutoApprove && (userPermissionValue & Permission.ADMIN)) { return true; } + + // Otherwise, do the normal bit checks for each required permission switch (options.type) { case 'and': - return permissions.every((permission) => !!(value & permission)); + return requiredPermissions.every( + (perm) => !!(userPermissionValue & perm) + ); case 'or': - return permissions.some((permission) => !!(value & permission)); + return requiredPermissions.some( + (perm) => !!(userPermissionValue & perm) + ); } - } else { - total = permissions; } - return !!(value & Permission.ADMIN) || !!(value & total); + // Handle single permission + const singlePerm = requiredPermissions[0]; + // If it's NOT an auto-approve permission, let admin pass automatically + if (!isAutoApprovePermission(singlePerm) && (userPermissionValue & Permission.ADMIN)) { + return true; + } + + // Otherwise, must explicitly match the permission bit + return !!(userPermissionValue & singlePerm); }; diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index aeb0480920..5cbe3a7119 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -44,7 +44,7 @@ class WatchlistSync { [ Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE, - Permission.AUTO_APPROVE_TV, + Permission.AUTO_REQUEST_TV, ], { type: 'or' } ) diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 50ea7c5a5f..235ba39271 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -684,6 +684,16 @@ userSettingsRoutes.get<{ id: string }, { permissions?: number }>( } ); +// Auto-approve permission bits that the owner can modify on themselves +// This allows admins to opt out of auto-approval for third-party tool integration +const AUTO_APPROVE_BITS = + Permission.AUTO_APPROVE | + Permission.AUTO_APPROVE_MOVIE | + Permission.AUTO_APPROVE_TV | + Permission.AUTO_APPROVE_4K | + Permission.AUTO_APPROVE_4K_MOVIE | + Permission.AUTO_APPROVE_4K_TV; + userSettingsRoutes.post< { id: string }, { permissions?: number }, @@ -703,7 +713,20 @@ userSettingsRoutes.post< return next({ status: 404, message: 'User not found.' }); } - // "Owner" user permissions cannot be modified, and users cannot set their own permissions + // Special case: Owner (ID 1) can modify their OWN auto-approve permissions + // This allows admins to opt out of auto-approval if desired + if (user.id === 1 && req.user?.id === 1) { + // Only allow changes to auto-approve bits, preserve all other permissions + const currentNonAutoApprove = user.permissions & ~AUTO_APPROVE_BITS; + const newAutoApprove = req.body.permissions & AUTO_APPROVE_BITS; + user.permissions = currentNonAutoApprove | newAutoApprove; + + await userRepository.save(user); + return res.status(200).json({ permissions: user.permissions }); + } + + // "Owner" user permissions cannot be modified by others, + // and users cannot set their own permissions (except owner for auto-approve above) if (user.id === 1 || req.user?.id === user.id) { return next({ status: 403, diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 922bde87d2..1de8dc24e2 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -48,15 +48,18 @@ const PermissionOption = ({ let disabled = false; let checked = hasPermission(option.permission, currentPermission); + // Check if this is an auto-approve permission + const isAutoApprove = autoApprovePermissions.includes(option.permission); + if ( - // Permissions for user ID 1 (Plex server owner) cannot be changed - (currentUser && currentUser.id === 1) || + // Permissions for user ID 1 (server owner) cannot be changed + // EXCEPT for auto-approve permissions (admins can opt out of auto-approval) + (currentUser && currentUser.id === 1 && !isAutoApprove) || // Admin permission automatically bypasses/grants all other permissions + // EXCEPT for auto-approve permissions (admins must explicitly have these) (option.permission !== Permission.ADMIN && + !isAutoApprove && hasPermission(Permission.ADMIN, currentPermission)) || - // Manage Requests permission automatically grants all Auto-Approve permissions - (autoApprovePermissions.includes(option.permission) && - hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || // Selecting a parent permission automatically selects all children (!!parent?.permission && hasPermission(parent.permission, currentPermission)) diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 23508f5177..3d80f2fbc3 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -248,9 +248,10 @@ const CollectionRequestModal = ({ is4k, ]); + // Note: MANAGE_REQUESTS is intentionally excluded to allow third-party tools + // to intercept admin requests for additional processing const hasAutoApprove = hasPermission( [ - Permission.MANAGE_REQUESTS, is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, ], diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 134a937f6a..78fabb6278 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -305,9 +305,10 @@ const MovieRequestModal = ({ ); } + // Note: MANAGE_REQUESTS is intentionally excluded to allow third-party tools + // to intercept admin requests for additional processing const hasAutoApprove = hasPermission( [ - Permission.MANAGE_REQUESTS, is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, ], diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 5d2249de85..8fbf233054 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -471,9 +471,10 @@ const TvRequestModal = ({ username: editRequest?.requestedBy.displayName, }) : null} + {/* Note: MANAGE_REQUESTS is intentionally excluded to allow third-party tools + to intercept admin requests for additional processing */} {hasPermission( [ - Permission.MANAGE_REQUESTS, is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, ], From 14b7124f5d1a7a5c51c347d1b7e1c6c1dbdbc9f4 Mon Sep 17 00:00:00 2001 From: Frank Gonnello Date: Sun, 21 Dec 2025 23:01:12 -0500 Subject: [PATCH 2/2] fix: restore permissions === 0 check for non-admin login When hasPermission is called with 0 (no permission required), return true immediately. This was accidentally removed during the admin auto-approve fix, breaking /auth/me for non-admin users. --- server/lib/permissions.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 5354b849f8..1686f903f8 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -67,12 +67,18 @@ export const hasPermission = ( userPermissionValue: number, options: PermissionCheckOptions = { type: 'and' } ): boolean => { + // If we are not checking any permissions, bail out and return true + // This handles isAuthenticated() called with no arguments (any logged-in user) + if (permissions === 0) { + return true; + } + // Normalize permissions to an array const requiredPermissions: Permission[] = Array.isArray(permissions) ? permissions : [permissions]; - // If we're not checking any permissions at all, return true + // If we're checking an empty array, return true if (requiredPermissions.length === 0) { return true; }