CVE-2022-2756: Server-Side Request Forgery (SSRF) (CWE-918)
value: Server-Side Request Forgery (SSRF) in GitHub repository kareadita/kavita prior to 0.5.4.1.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 6.5
Published: 2022-08-10T16:15Z
Repository: https://github.com/kareadita/kavita
Language: C#
Files changed: API/Controllers/AccountController.cs, API/Controllers/AdminController.cs, API/Controllers/BaseApiController.cs, API/Controllers/FallbackController.cs, API/Controllers/ImageController.cs, API/Controllers/OPDSController.cs, API/Controllers/ReaderController.cs, API/Controllers/ReadingListController.cs, API/Controllers/ThemeController.cs, API/Controllers/UploadController.cs, API/Data/Repositories/ReadingListRepository.cs, API/Extensions/IdentityServiceExtensions.cs, API/Services/ImageService.cs, Kavita.Common/Kavita.Common.csproj, README.md, SECURITY.md
Commit date: 2022-08-08 13:22:38 -0700
Commit message: v0.5.4.1 - Security Hotfix (#1414)

* Updated our security file with our Huntr.

* Fixed a security vulnerability where through the API, an unauthorized user could delete/modify reading lists that did not belong to them.

Fixed a bug where when creating a reading list with the name of another users, the API would throw an exception (but reading list would still get created)

* Fixed a security vulnerability where through the API, an unauthorized user could delete/modify reading lists that did not belong to them.

Fixed a bug where when creating a reading list with the name of another users, the API would throw an exception (but reading list would still get created)

* Ensure all reading list apis are authorized

* Ensured all APIs require authentication, except those that explicitly don't. All APIs are default requiring Authentication.

Fixed a security vulnerability which would allow a user to take over an admin account.

* Fixed a bug where cover-upload would accept filenames that were not expected.

* Explicitly check that a user has access to the pdf file before we serve it back.

* Enabled lock out when invalid user auth occurs. After 5 invalid auths, the user account will be locked out for 10 mins.

* Version bump for hotfix release
Diff: diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 23601330..9afd4be7 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -68,17 +68,25 @@ namespace API.Controllers
         /// <summary>
         /// Update a user's password
         /// </summary>
         /// <param name="resetPasswordDto"></param>
         /// <returns></returns>
+        [AllowAnonymous]
         [HttpPost("reset-password")]
         public async Task<ActionResult> UpdatePassword(ResetPasswordDto resetPasswordDto)
         {
+            // TODO: Log this request to Audit Table
             _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName);
-            var user = await _userManager.Users.SingleAsync(x => x.UserName == resetPasswordDto.UserName);
 
-            if (resetPasswordDto.UserName != User.GetUsername() && !(User.IsInRole(PolicyConstants.AdminRole) || User.IsInRole(PolicyConstants.ChangePasswordRole)))
+            var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName);
+            if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system
+
+
+            if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || User.IsInRole(PolicyConstants.AdminRole)))
+                return Unauthorized("You are not permitted to this operation.");
+
+            if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole))
                 return Unauthorized("You are not permitted to this operation.");
 
             var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password);
             if (errors.Any())
             {
@@ -92,10 +100,11 @@ namespace API.Controllers
         /// <summary>
         /// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed
         /// </summary>
         /// <param name="registerDto"></param>
         /// <returns></returns>
+        [AllowAnonymous]
         [HttpPost("register")]
         public async Task<ActionResult<UserDto>> RegisterFirstUser(RegisterDto registerDto)
         {
             var admins = await _userManager.GetUsersInRoleAsync("Admin");
             if (admins.Count > 0) return BadRequest("Not allowed");
@@ -153,10 +162,11 @@ namespace API.Controllers
         /// <summary>
         /// Perform a login. Will send JWT Token of the logged in user back.
         /// </summary>
         /// <param name="loginDto"></param>
         /// <returns></returns>
+        [AllowAnonymous]
         [HttpPost("login")]
         public async Task<ActionResult<UserDto>> Login(LoginDto loginDto)
         {
             var user = await _userManager.Users
                 .Include(u => u.UserPreferences)
@@ -171,18 +181,18 @@ namespace API.Controllers
                 _logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName);
                 return Unauthorized(
                     "You are missing an email on your account. Please wait while we migrate your account.");
             }
 
-            if (!validPassword)
+            var result = await _signInManager
+                .CheckPasswordSignInAsync(user, loginDto.Password, true);
+
+            if (result.IsLockedOut)
             {
-                return Unauthorized("Your credentials are not correct");
+                return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes.");
             }
 
-            var result = await _signInManager
-                .CheckPasswordSignInAsync(user, loginDto.Password, false);
-
             if (!result.Succeeded)
             {
                 return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct.");
             }
 
@@ -210,10 +220,11 @@ namespace API.Controllers
         /// <summary>
         /// Refreshes the user's JWT token
         /// </summary>
         /// <param name="tokenRequestDto"></param>
         /// <returns></returns>
+        [AllowAnonymous]
         [HttpPost("refresh-token")]
         public async Task<ActionResult<TokenRequestDto>> RefreshToken([FromBody] TokenRequestDto tokenRequestDto)
         {
             var token = await _tokenService.ValidateRefreshToken(tokenRequestDto);
             if (token == null)
@@ -460,10 +471,11 @@ namespace API.Controllers
             }
 
             return BadRequest("There was an error setting up your account. Please check the logs");
         }
 
+        [AllowAnonymous]
         [HttpPost("confirm-email")]
         public async Task<ActionResult<UserDto>> ConfirmEmail(ConfirmEmailDto dto)
         {
             var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
 
diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs
index 2c945b5f..045cc63d 100644
--- a/API/Controllers/AdminController.cs
+++ b/API/Controllers/AdminController.cs
@@ -1,7 +1,8 @@
 ﻿using System.Threading.Tasks;
 using API.Entities;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Identity;
 using Microsoft.AspNetCore.Mvc;
 
 namespace API.Controllers
 {
@@ -16,10 +17,11 @@ namespace API.Controllers
 
         /// <summary>
         /// Checks if an admin exists on the system. This is essentially a check to validate if the system has been setup.
         /// </summary>
         /// <returns></returns>
+        [AllowAnonymous]
         [HttpGet("exists")]
         public async Task<ActionResult<bool>> AdminExists()
         {
             var users = await _userManager.GetUsersInRoleAsync("Admin");
             return users.Count > 0;
diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs
index bb3886ab..dfedd7a0 100644
--- a/API/Controllers/BaseApiController.cs
+++ b/API/Controllers/BaseApiController.cs
@@ -1,10 +1,12 @@
-﻿using Microsoft.AspNetCore.Mvc;
+﻿using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
 
 namespace API.Controllers
 {
     [ApiController]
     [Route("api/[controller]")]
+    [Authorize]
     public class BaseApiController : ControllerBase
     {
     }
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs
index ae8bad21..a765269b 100644
--- a/API/Controllers/FallbackController.cs
+++ b/API/Controllers/FallbackController.cs
@@ -1,24 +1,26 @@
 ﻿using System.IO;
 using API.Services;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Mvc;
 
-namespace API.Controllers
+namespace API.Controllers;
+
+[AllowAnonymous]
+public class FallbackController : Controller
 {
-    public class FallbackController : Controller
-    {
-        // ReSharper disable once S4487
-        // ReSharper disable once NotAccessedField.Local
-        private readonly ITaskScheduler _taskScheduler;
+    // ReSharper disable once S4487
+    // ReSharper disable once NotAccessedField.Local
+    private readonly ITaskScheduler _taskScheduler;
 
-        public FallbackController(ITaskScheduler taskScheduler)
-        {
-            // This is used to load TaskScheduler on startup without having to navigate to a Controller that uses.
-            _taskScheduler = taskScheduler;
-        }
+    public FallbackController(ITaskScheduler taskScheduler)
+    {
+        // This is used to load TaskScheduler on startup without having to navigate to a Controller that uses.
+        _taskScheduler = taskScheduler;
+    }
 
-        public ActionResult Index()
-        {
-            return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
-        }
+    public ActionResult Index()
+    {
+        return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
     }
 }
+
diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs
index b34b9f6b..1a19666d 100644
--- a/API/Controllers/ImageController.cs
+++ b/API/Controllers/ImageController.cs
@@ -135,10 +135,12 @@ namespace API.Controllers
         /// <returns></returns>
         [AllowAnonymous]
         [HttpGet("cover-upload")]
         public ActionResult GetCoverUploadImage(string filename)
         {
+            if (filename.Contains("..")) return BadRequest("Invalid Filename");
+
             var path = Path.Join(_directoryService.TempDirectory, filename);
             if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist");
             var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
 
             Response.AddCacheHeader(path);
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index 4dee326b..d5be7785 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -15,14 +15,16 @@ using API.Entities;
 using API.Entities.Enums;
 using API.Extensions;
 using API.Helpers;
 using API.Services;
 using Kavita.Common;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Mvc;
 
 namespace API.Controllers;
 
+[AllowAnonymous]
 public class OpdsController : BaseApiController
 {
     private readonly IUnitOfWork _unitOfWork;
     private readonly IDownloadService _downloadService;
     private readonly IDirectoryService _directoryService;
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index 758c6d5a..3aab19e8 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -55,10 +55,15 @@ namespace API.Controllers
         {
 
             var chapter = await _cacheService.Ensure(chapterId);
             if (chapter == null) return BadRequest("There was an issue finding pdf file for reading");
 
+            // Validate the user has access to the PDF
+            var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id,
+                await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()));
+            if (series == null) return BadRequest("Invalid Access");
+
             try
             {
                 var path = _cacheService.GetCachedFile(chapter);
                 if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should.");
 
diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs
index a4285431..ccd27a78 100644
--- a/API/Controllers/ReadingListController.cs
+++ b/API/Controllers/ReadingListController.cs
@@ -1,19 +1,22 @@
 ﻿using System.Collections.Generic;
 using System.Linq;
 using System.Threading.Tasks;
 using API.Comparators;
 using API.Data;
+using API.Data.Repositories;
 using API.DTOs.ReadingLists;
 using API.Entities;
 using API.Extensions;
 using API.Helpers;
 using API.SignalR;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Mvc;
 
 namespace API.Controllers
 {
+    [Authorize]
     public class ReadingListController : BaseApiController
     {
         private readonly IUnitOfWork _unitOfWork;
         private readonly IEventHub _eventHub;
         private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
@@ -71,12 +74,22 @@ namespace API.Controllers
         public async Task<ActionResult<IEnumerable<ReadingListItemDto>>> GetListForUser(int readingListId)
         {
             var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
             var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
             return Ok(items);
+        }
+
+        private async Task<AppUser?> UserHasReadingListAccess(int readingListId)
+        {
+            var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
+                AppUserIncludes.ReadingLists);
+            if (user.ReadingLists.SingleOrDefault(rl => rl.Id == readingListId) == null && !await _unitOfWork.UserRepository.IsUserAdminAsync(user))
+            {
+                return null;
+            }
 
-            //return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList()));
+            return user;
         }
 
         /// <summary>
         /// Updates an items position
         /// </summary>
@@ -84,10 +97,15 @@ namespace API.Controllers
         /// <returns></returns>
         [HttpPost("update-position")]
         public async Task<ActionResult> UpdateListItemPosition(UpdateReadingListPosition dto)
         {
             // Make sure UI buffers events
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
             var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList();
             var item = items.Find(r => r.Id == dto.ReadingListItemId);
             items.Remove(item);
             items.Insert(dto.ToPosition, item);
 
@@ -110,14 +128,19 @@ namespace API.Controllers
         /// <param name="dto"></param>
         /// <returns></returns>
         [HttpPost("delete-item")]
         public async Task<ActionResult> DeleteListItem(UpdateReadingListPosition dto)
         {
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
+
             var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
             readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).ToList();
 
-
             var index = 0;
             foreach (var readingListItem in readingList.Items)
             {
                 readingListItem.Order = index;
                 index++;
@@ -139,13 +162,18 @@ namespace API.Controllers
         /// <param name="readingListId"></param>
         /// <returns></returns>
         [HttpPost("remove-read")]
         public async Task<ActionResult> DeleteReadFromList([FromQuery] int readingListId)
         {
-            var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
-            var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
-            items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList());
+            var user = await UserHasReadingListAccess(readingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
+
+            var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id);
+            items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(user.Id, items.ToList());
 
             // Collect all Ids to remove
             var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id);
 
             try
@@ -174,19 +202,17 @@ namespace API.Controllers
         /// <param name="readingListId"></param>
         /// <returns></returns>
         [HttpDelete]
         public async Task<ActionResult> DeleteList([FromQuery] int readingListId)
         {
-            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
-            var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
-            var readingList = user.ReadingLists.SingleOrDefault(r => r.Id == readingListId);
-            if (readingList == null && !isAdmin)
+            var user = await UserHasReadingListAccess(readingListId);
+            if (user == null)
             {
-                return BadRequest("User is not associated with this reading list");
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
             }
 
-            readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
+            var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
 
             user.ReadingLists.Remove(readingList);
 
             if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
             {
@@ -211,17 +237,18 @@ namespace API.Controllers
             if (hasExisting)
             {
                 return BadRequest("A list of this name already exists");
             }
 
-            user.ReadingLists.Add(DbFactory.ReadingList(dto.Title, string.Empty, false));
+            var readingList = DbFactory.ReadingList(dto.Title, string.Empty, false);
+            user.ReadingLists.Add(readingList);
 
             if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list");
 
             await _unitOfWork.CommitAsync();
 
-            return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(dto.Title));
+            return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(user.Id, dto.Title));
         }
 
         /// <summary>
         /// Update the properties (title, summary) of a reading list
         /// </summary>
@@ -231,11 +258,15 @@ namespace API.Controllers
         public async Task<ActionResult> UpdateList(UpdateReadingListDto dto)
         {
             var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
             if (readingList == null) return BadRequest("List does not exist");
 
-
+            var user = await UserHasReadingListAccess(readingList.Id);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
 
             if (!string.IsNullOrEmpty(dto.Title))
             {
                 readingList.Title = dto.Title; // Should I check if this is unique?
                 readingList.NormalizedTitle = Parser.Parser.Normalize(readingList.Title);
@@ -275,11 +306,16 @@ namespace API.Controllers
         /// <param name="dto"></param>
         /// <returns></returns>
         [HttpPost("update-by-series")]
         public async Task<ActionResult> UpdateListBySeries(UpdateReadingListBySeriesDto dto)
         {
-            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
+
             var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
             if (readingList == null) return BadRequest("Reading List does not exist");
             var chapterIdsForSeries =
                 await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId});
 
@@ -312,11 +348,15 @@ namespace API.Controllers
         /// <param name="dto"></param>
         /// <returns></returns>
         [HttpPost("update-by-multiple")]
         public async Task<ActionResult> UpdateListByMultiple(UpdateReadingListByMultipleDto dto)
         {
-            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
             var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
             if (readingList == null) return BadRequest("Reading List does not exist");
 
             var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds);
             foreach (var chapterId in dto.ChapterIds)
@@ -352,11 +392,15 @@ namespace API.Controllers
         /// <param name="dto"></param>
         /// <returns></returns>
         [HttpPost("update-by-multiple-series")]
         public async Task<ActionResult> UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto)
         {
-            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
             var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
             if (readingList == null) return BadRequest("Reading List does not exist");
 
             var ids = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray());
 
@@ -386,13 +430,18 @@ namespace API.Controllers
         }
 
         [HttpPost("update-by-volume")]
         public async Task<ActionResult> UpdateListByVolume(UpdateReadingListByVolumeDto dto)
         {
-            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
             var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
             if (readingList == null) return BadRequest("Reading List does not exist");
+
             var chapterIdsForVolume =
                 (await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList();
 
             // If there are adds, tell tracking this has been modified
             if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList))
@@ -417,11 +466,15 @@ namespace API.Controllers
         }
 
         [HttpPost("update-by-chapter")]
         public async Task<ActionResult> UpdateListByChapter(UpdateReadingListByChapterDto dto)
         {
-            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
+            var user = await UserHasReadingListAccess(dto.ReadingListId);
+            if (user == null)
+            {
+                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
+            }
             var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
             if (readingList == null) return BadRequest("Reading List does not exist");
 
             // If there are adds, tell tracking this has been modified
             if (await AddChaptersToReadingList(dto.SeriesId, new List<int>() { dto.ChapterId }, readingList))
diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs
index bf68e864..69793df4 100644
--- a/API/Controllers/ThemeController.cs
+++ b/API/Controllers/ThemeController.cs
@@ -22,10 +22,11 @@ public class ThemeController : BaseApiController
         _unitOfWork = unitOfWork;
         _themeService = themeService;
         _taskScheduler = taskScheduler;
     }
 
+    [AllowAnonymous]
     [HttpGet]
     public async Task<ActionResult<IEnumerable<SiteThemeDto>>> GetThemes()
     {
         return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
     }
diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs
index ca84acc8..c7def140 100644
--- a/API/Controllers/UploadController.cs
+++ b/API/Controllers/UploadController.cs
@@ -57,10 +57,12 @@ namespace API.Controllers
                     .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
 
                 if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
                     return BadRequest($"Could not download file");
 
+                if (!await _imageService.IsImage(path)) return BadRequest("Url does not return a valid image");
+
                 return $"coverupload_{dateString}.{format}";
             }
             catch (FlurlHttpException ex)
             {
                 // Unauthorized
diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs
index 0294d622..26b2f757 100644
--- a/API/Data/Repositories/ReadingListRepository.cs
+++ b/API/Data/Repositories/ReadingListRepository.cs
@@ -15,11 +15,11 @@ public interface IReadingListRepository
     Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams);
     Task<ReadingList> GetReadingListByIdAsync(int readingListId);
     Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId);
     Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId);
     Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
-    Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title);
+    Task<ReadingListDto> GetReadingListDtoByTitleAsync(int userId, string title);
     Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
 
     Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
         bool includePromoted);
     void Remove(ReadingListItem item);
@@ -209,14 +209,14 @@ public class ReadingListRepository : IReadingListRepository
         }
 
         return items;
     }
 
-    public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title)
+    public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(int userId, string title)
     {
         return await _context.ReadingList
-            .Where(r => r.Title.Equals(title))
+            .Where(r => r.Title.Equals(title) && r.AppUserId == userId)
             .ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
             .SingleOrDefaultAsync();
     }
 
     public async Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId)
diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs
index 043e5c91..5cc4718b 100644
--- a/API/Extensions/IdentityServiceExtensions.cs
+++ b/API/Extensions/IdentityServiceExtensions.cs
@@ -1,6 +1,7 @@
-﻿using System.Text;
+﻿using System;
+using System.Text;
 using System.Threading.Tasks;
 using API.Constants;
 using API.Data;
 using API.Entities;
 using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -30,10 +31,15 @@ namespace API.Extensions
                     opt.Password.RequireUppercase = false;
                     opt.Password.RequireNonAlphanumeric = false;
                     opt.Password.RequiredLength = 6;
 
                     opt.SignIn.RequireConfirmedEmail = true;
+
+                    opt.Lockout.AllowedForNewUsers = true;
+                    opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);
+                    opt.Lockout.MaxFailedAccessAttempts = 5;
+
                 })
                 .AddTokenProvider<DataProtectorTokenProvider<AppUser>>(TokenOptions.DefaultProvider)
                 .AddRoles<AppRole>()
                 .AddRoleManager<RoleManager<AppRole>>()
                 .AddSignInManager<SignInManager<AppUser>>()
diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs
index a1ab70fd..4dd31db8 100644
--- a/API/Services/ImageService.cs
+++ b/API/Services/ImageService.cs
@@ -25,10 +25,12 @@ public interface IImageService
     /// Converts the passed image to webP and outputs it in the same directory
     /// </summary>
     /// <param name="filePath">Full path to the image to convert</param>
     /// <returns>File of written webp image</returns>
     Task<string> ConvertToWebP(string filePath, string outputPath);
+
+    Task<bool> IsImage(string filePath);
 }
 
 public class ImageService : IImageService
 {
     private readonly ILogger<ImageService> _logger;
@@ -113,10 +115,27 @@ public class ImageService : IImageService
         using var sourceImage = await SixLabors.ImageSharp.Image.LoadAsync(filePath);
         await sourceImage.SaveAsWebpAsync(outputFile);
         return outputFile;
     }
 
+    public async Task<bool> IsImage(string filePath)
+    {
+        try
+        {
+            var info = await SixLabors.ImageSharp.Image.IdentifyAsync(filePath);
+            if (info == null) return false;
+
+            return true;
+        }
+        catch (Exception ex)
+        {
+            /* Swallow Exception */
+        }
+
+        return false;
+    }
+
 
     /// <inheritdoc />
     public string CreateThumbnailFromBase64(string encodedImage, string fileName)
     {
         try
diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj
index 7c99c09f..debd9b5a 100644
--- a/Kavita.Common/Kavita.Common.csproj
+++ b/Kavita.Common/Kavita.Common.csproj
@@ -2,11 +2,11 @@
 
     <PropertyGroup>
         <TargetFramework>net6.0</TargetFramework>
         <Company>kavitareader.com</Company>
         <Product>Kavita</Product>
-        <AssemblyVersion>0.5.4.0</AssemblyVersion>
+        <AssemblyVersion>0.5.4.1</AssemblyVersion>
         <NeutralLanguage>en</NeutralLanguage>
     </PropertyGroup>
 
     <ItemGroup>
       <PackageReference Include="Flurl.Http" Version="3.2.4" />
diff --git a/README.md b/README.md
index e4f47b79..b92725d0 100644
--- a/README.md
+++ b/README.md
@@ -19,16 +19,16 @@ your reading collection with your friends and family!
 
 ## Goals
 - [x] Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, 7zip, raw images) and Books (epub, pdf)
 - [x] First class responsive readers that work great on any device (phone, tablet, desktop)
 - [x] Dark mode and customizable theming support
-- [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books
+- [ ] Provide a plugin system to allow external metadata integration and scrobbling for read status, ratings, and reviews
 - [x] Metadata should allow for collections, want to read integration from 3rd party services, genres.
 - [x] Ability to manage users, access, and ratings
-- [ ] Ability to sync ratings and reviews to external services
 - [x] Fully Accessible with active accessibility audits
 - [x] Dedicated webtoon reading mode
+- [ ] Full localization support
 - [ ] And so much [more...](https://github.com/Kareadita/Kavita/projects)
 
 ## Support
 [![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/KavitaManga/)
 [![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/eczRp9eeem)
@@ -91,10 +91,13 @@ Thank you to [<img src="/Logo/jetbrains.svg" alt="" width="32"> JetBrains](http:
 * [<img src="/Logo/dottrace.svg" alt="" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
 
 ## Palace-Designs
 We would like to extend a big thank you to [<img src="/Logo/hosting-sponsor.png" alt="" width="128">](https://www.palace-designs.com/) who hosts our infrastructure pro-bono.
 
+## Huntr
+We would like to extend a big thank you to [Huntr](https://huntr.dev/repos/kareadita/kavita) who has worked with Kavita in reporting security vulnerabilities. If you are interested in 
+being paid to help secure Kavita, please give them a try.
 
 ### License
 
 * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
 * Copyright 2020-2022
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..56f010fb
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,10 @@
+# Security Policy
+
+## Supported Versions
+
+Security is maintained on latest stable version only. 
+
+## Reporting a Vulnerability
+
+
+Please reach out to majora2007 via our Discord or you can (and should) report your vulnerability via [Huntr](https://huntr.dev/repos/kareadita/kavita).

Vulnerable code:             var user = await _userManager.Users.SingleAsync(x => x.UserName == resetPasswordDto.UserName);
            if (resetPasswordDto.UserName != User.GetUsername() && !(User.IsInRole(PolicyConstants.AdminRole) || User.IsInRole(PolicyConstants.ChangePasswordRole)))
            if (!validPassword)
                return Unauthorized("Your credentials are not correct");
            var result = await _signInManager
                .CheckPasswordSignInAsync(user, loginDto.Password, false);

﻿using Microsoft.AspNetCore.Mvc;
}
namespace API.Controllers
    public class FallbackController : Controller
    {
        // ReSharper disable once S4487
        // ReSharper disable once NotAccessedField.Local
        private readonly ITaskScheduler _taskScheduler;
        public FallbackController(ITaskScheduler taskScheduler)
        {
            // This is used to load TaskScheduler on startup without having to navigate to a Controller that uses.
            _taskScheduler = taskScheduler;
        }
        public ActionResult Index()
        {
            return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
        }
            //return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList()));

            var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
            var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
            items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList());
            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
            var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
            var readingList = user.ReadingLists.SingleOrDefault(r => r.Id == readingListId);
            if (readingList == null && !isAdmin)
                return BadRequest("User is not associated with this reading list");
            readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
            user.ReadingLists.Add(DbFactory.ReadingList(dto.Title, string.Empty, false));
            return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(dto.Title));

            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
            var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
    Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title);
    public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title)
            .Where(r => r.Title.Equals(title))
﻿using System.Text;
        <AssemblyVersion>0.5.4.0</AssemblyVersion>
- [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books
- [ ] Ability to sync ratings and reviews to external services
Fixed code:         [AllowAnonymous]
            // TODO: Log this request to Audit Table
            var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName);
            if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system


            if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || User.IsInRole(PolicyConstants.AdminRole)))
                return Unauthorized("You are not permitted to this operation.");

            if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole))
        [AllowAnonymous]
        [AllowAnonymous]
            var result = await _signInManager
                .CheckPasswordSignInAsync(user, loginDto.Password, true);

            if (result.IsLockedOut)
                return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes.");
        [AllowAnonymous]
        [AllowAnonymous]
using Microsoft.AspNetCore.Authorization;
        [AllowAnonymous]
﻿using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
    [Authorize]
}
using Microsoft.AspNetCore.Authorization;
namespace API.Controllers;

[AllowAnonymous]
public class FallbackController : Controller
    // ReSharper disable once S4487
    // ReSharper disable once NotAccessedField.Local
    private readonly ITaskScheduler _taskScheduler;
    public FallbackController(ITaskScheduler taskScheduler)
    {
        // This is used to load TaskScheduler on startup without having to navigate to a Controller that uses.
        _taskScheduler = taskScheduler;
    }
    public ActionResult Index()
    {
        return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");

            if (filename.Contains("..")) return BadRequest("Invalid Filename");

using Microsoft.AspNetCore.Authorization;
[AllowAnonymous]
            // Validate the user has access to the PDF
            var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id,
                await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()));
            if (series == null) return BadRequest("Invalid Access");

using API.Data.Repositories;
using Microsoft.AspNetCore.Authorization;
    [Authorize]
        }

        private async Task<AppUser?> UserHasReadingListAccess(int readingListId)
        {
            var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
                AppUserIncludes.ReadingLists);
            if (user.ReadingLists.SingleOrDefault(rl => rl.Id == readingListId) == null && !await _unitOfWork.UserRepository.IsUserAdminAsync(user))
            {
                return null;
            }
            return user;
            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }
            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }

            var user = await UserHasReadingListAccess(readingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }

            var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id);
            items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(user.Id, items.ToList());
            var user = await UserHasReadingListAccess(readingListId);
            if (user == null)
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
            var readingList = DbFactory.ReadingList(dto.Title, string.Empty, false);
            user.ReadingLists.Add(readingList);
            return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(user.Id, dto.Title));
            var user = await UserHasReadingListAccess(readingList.Id);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }
            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }

            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }
            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }
            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }

            var user = await UserHasReadingListAccess(dto.ReadingListId);
            if (user == null)
            {
                return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
            }
    [AllowAnonymous]
                if (!await _imageService.IsImage(path)) return BadRequest("Url does not return a valid image");

    Task<ReadingListDto> GetReadingListDtoByTitleAsync(int userId, string title);
    public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(int userId, string title)
            .Where(r => r.Title.Equals(title) && r.AppUserId == userId)
﻿using System;
using System.Text;

                    opt.Lockout.AllowedForNewUsers = true;
                    opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);
                    opt.Lockout.MaxFailedAccessAttempts = 5;


    Task<bool> IsImage(string filePath);
    public async Task<bool> IsImage(string filePath)
    {
        try
        {
            var info = await SixLabors.ImageSharp.Image.IdentifyAsync(filePath);
            if (info == null) return false;

            return true;
        }
        catch (Exception ex)
        {
            /* Swallow Exception */
        }

        return false;
    }

        <AssemblyVersion>0.5.4.1</AssemblyVersion>
- [ ] Provide a plugin system to allow external metadata integration and scrobbling for read status, ratings, and reviews
- [ ] Full localization support
## Huntr
We would like to extend a big thank you to [Huntr](https://huntr.dev/repos/kareadita/kavita) who has worked with Kavita in reporting security vulnerabilities. If you are interested in 
being paid to help secure Kavita, please give them a try.
# Security Policy

## Supported Versions

Security is maintained on latest stable version only. 

## Reporting a Vulnerability


Please reach out to majora2007 via our Discord or you can (and should) report your vulnerability via [Huntr](https://huntr.dev/repos/kareadita/kavita).