From 811963749ed7452f6ab2642634738a365bbb9f32 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Apr 2026 17:05:52 +0200 Subject: [PATCH] Fix cross-user job history leak --- Data/JobTrackerContext.cs | 3 + .../JobApplicationsAuthorizationTests.cs | 63 +++++++ .../Controllers/JobApplicationsController.cs | 3 + .../M015-s02-probe-results.json | 158 ++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs create mode 100644 docs/security-assessments/M015-s02-probe-results.json diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 49994c8..5b955a6 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -85,6 +85,9 @@ namespace JobTrackerApi.Data .HasForeignKey(a => a.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.JobApplication.OwnerUserId == CurrentUserId); + modelBuilder.Entity() .HasOne(e => e.JobApplication) .WithMany(j => j.Events) diff --git a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs new file mode 100644 index 0000000..fb29a95 --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobApplicationsAuthorizationTests +{ + [Fact] + public async Task GetHistory_returns_not_found_for_other_users_job() + { + var dbName = Guid.NewGuid().ToString(); + await using var ownerDb = CreateDb(dbName, "owner-1"); + var company = new Company { Name = "Acme", OwnerUserId = "owner-1" }; + ownerDb.Companies.Add(company); + await ownerDb.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Secret Job", CompanyId = company.Id, OwnerUserId = "owner-1" }; + ownerDb.JobApplications.Add(job); + await ownerDb.SaveChangesAsync(); + + ownerDb.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", Note = "owner only" }); + await ownerDb.SaveChangesAsync(); + + await using var attackerDb = CreateDb(dbName, "other-user"); + var controller = CreateController(attackerDb); + + var result = await controller.GetHistory(job.Id, CancellationToken.None); + + Assert.IsType(result.Result); + } + + private static JobTrackerContext CreateDb(string dbName, string? userId) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns(userId); + return new JobTrackerContext(options, currentUser.Object); + } + + private static JobApplicationsController CreateController(JobTrackerContext db) + { + var summarizer = new Mock(); + var users = TestHostFactory.CreateUserManager(); + return new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object, NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index c334b1b..58f6884 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1676,6 +1676,9 @@ Canonical profile: [HttpGet("{id:int}/history")] public async Task>> GetHistory([FromRoute] int id, CancellationToken cancellationToken) { + var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken); + if (!exists) return NotFound(); + var items = await _db.JobEvents .AsNoTracking() .Where(e => e.JobApplicationId == id) diff --git a/docs/security-assessments/M015-s02-probe-results.json b/docs/security-assessments/M015-s02-probe-results.json new file mode 100644 index 0000000..1849fb6 --- /dev/null +++ b/docs/security-assessments/M015-s02-probe-results.json @@ -0,0 +1,158 @@ +[ + { + "method": "GET", + "path": "/attachments/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-2dad72a5823538bb6b92689d4569fe43-cf77b418a0f46da4-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/attachments/download/1", + "status": 404, + "preview": "", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "PATCH", + "path": "/attachments/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-d63b1ce08c89ba9ed4cedb01a7de8350-db6e76ebddea4885-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "DELETE", + "path": "/attachments/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-367415d13b5de0f54c2018849f526b04-df45573d91fb9c07-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/correspondence/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-df329ad92752ad3337e26fce98a92693-6cf9f400655056a8-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "DELETE", + "path": "/correspondence/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-286bd304cbc2b454c90fcd119b02aa47-f611583cbf3aacde-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/jobapplications/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-4cb171cc0edcfeee0f1b609c8feb8219-fb961ff6e8690e99-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "PUT", + "path": "/jobapplications/1", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-35f21fc1525cd7f0d397f1b92bdab7b7-f59dbe90203598dc-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "PATCH", + "path": "/jobapplications/1/followup", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-99cfcce88bf8e8fe44bb35f04ba88d48-122a6284b4914f60-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/jobapplications/1/history", + "status": 200, + "preview": "[{\"id\":1,\"type\":\"Created\",\"oldValue\":null,\"newValue\":null,\"note\":null,\"at\":\"2026-04-11T16:56:28.2097864\"}]", + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/jobapplications/1/timeline", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-1aa65bd46ff7617ccc1dfeae45c45dc5-d275b3da1ba9d2c5-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/jobapplications/1/tailored-cv-draft", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-6e28c94856419924997eda53896f0e6c-fcf5a60dd2947b9a-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + }, + { + "method": "GET", + "path": "/jobapplications/1/followup-draft", + "status": 404, + "preview": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-f0ff37e63ea871aaeb34155de0d358bf-5f913a4b792c2e0d-00\"}", + "headers": { + "Content-Type": "application/problem+json; charset=utf-8", + "Date": "Sat, 11 Apr 2026 15:02:23 GMT", + "Server": "Kestrel", + "Transfer-Encoding": "chunked" + } + } +] \ No newline at end of file