From e376aed887f42df1cf58426829b25de84820608a Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:36:20 +0100 Subject: [PATCH] fix(blogacl): restrict Circle + BlogAcl reads and writes to caller's own data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the data-leak holes that survived the move of these controllers from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its membership should never be visible, modifiable, or deletable by anyone other than its owner. BlogAclApiController: - GetBlogACL() was returning the full table; now filters by Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed) so EF Core can push the filter into SQL instead of materialising the whole table. - Other endpoints (GetById, Put, Post, Delete) already enforced ownership; left as is. CircleApiController: - GetCircle() (no id) now filters by OwnerId. - GetCircle(id) now requires c.Id == id && c.OwnerId == uid; returns 404 (not 403) on miss to avoid leaking the existence of someone else's circle. - PutCircle verifies the existing record is owned by the caller, then forces circle.OwnerId = uid on the body (the client's value is ignored). Returns ChallengeResult when the caller doesn't own the record. - PostCircle forces circle.OwnerId = uid (was trusting the body). - DeleteCircle now filters by OwnerId; 404 on miss. All checks use the same source of truth (User.FindFirstValue( ClaimTypes.NameIdentifier)) that the existing BlogAclApiController authz code already relies on. --- .../Controllers/BlogAclApiController.cs | 13 ++++- .../Controllers/CircleApiController.cs | 58 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs index 3fbd89cf..aa81f9d5 100644 --- a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,11 +19,19 @@ namespace Yavsc.Blogs.Controllers _context = context; } - // GET: api/BlogAclApi + /// + /// Returns the ACL entries for the caller's own blog posts. + /// Blog posts (and therefore their ACLs) are private to their + /// author — the API never exposes another user's ACL. + /// + // GET: api/blogacl [HttpGet] public IEnumerable GetBlogACL() { - return _context.CircleAuthorizationToBlogPost; + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + return _context.CircleAuthorizationToBlogPost + .Include(a => a.Allowed) + .Where(a => a.Allowed.OwnerId == uid); } // GET: api/BlogAclApi/5 diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index b5434f83..368b488a 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -1,3 +1,5 @@ +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Yavsc.Models; @@ -17,14 +19,22 @@ namespace Yavsc.Blogs.Controllers _context = context; } - // GET: api/CircleApi + /// + /// Returns the caller's own circles. Circles are personal — + /// the API never exposes another user's circles, even by id. + /// + // GET: api/circle [HttpGet] public IEnumerable GetCircle() { - return _context.Circle; + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + return _context.Circle.Where(c => c.OwnerId == uid); } - // GET: api/CircleApi/5 + /// + /// Returns a single circle only when it belongs to the caller. + /// + // GET: api/circle/5 [HttpGet("{id}", Name = "GetCircle")] public async Task GetCircle([FromRoute] long id) { @@ -33,7 +43,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - Circle circle = await _context.Circle.SingleAsync(m => m.Id == id); + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + Circle circle = await _context.Circle.SingleOrDefaultAsync( + m => m.Id == id && m.OwnerId == uid); if (circle == null) { @@ -43,7 +55,12 @@ namespace Yavsc.Blogs.Controllers return Ok(circle); } - // PUT: api/CircleApi/5 + /// + /// Replaces a circle. The caller must own it; the server + /// reasserts ownership regardless of any OwnerId the client + /// tries to put in the body. + /// + // PUT: api/circle/5 [HttpPut("{id}")] public async Task PutCircle([FromRoute] long id, [FromBody] Circle circle) { @@ -57,6 +74,16 @@ namespace Yavsc.Blogs.Controllers return BadRequest(); } + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var existing = await _context.Circle.SingleOrDefaultAsync( + c => c.Id == id && c.OwnerId == uid); + if (existing is null) + { + return new ChallengeResult(); + } + + // Force OwnerId to the caller; the body value is ignored. + circle.OwnerId = uid; _context.Entry(circle).State = EntityState.Modified; try @@ -78,7 +105,11 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - // POST: api/CircleApi + /// + /// Creates a circle owned by the caller. The server overwrites + /// any OwnerId the client sends in the body. + /// + // POST: api/circle [HttpPost] public async Task PostCircle([FromBody] Circle circle) { @@ -87,6 +118,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + circle.OwnerId = uid; + _context.Circle.Add(circle); try { @@ -107,7 +141,13 @@ namespace Yavsc.Blogs.Controllers return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle); } - // DELETE: api/CircleApi/5 + /// + /// Deletes a circle only if the caller owns it. Returns 404 + /// (not 403) when the circle does not exist or is not owned + /// by the caller, to avoid leaking the existence of someone + /// else's circle. + /// + // DELETE: api/circle/5 [HttpDelete("{id}")] public async Task DeleteCircle([FromRoute] long id) { @@ -116,7 +156,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - Circle circle = await _context.Circle.SingleAsync(m => m.Id == id); + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + Circle circle = await _context.Circle.SingleOrDefaultAsync( + m => m.Id == id && m.OwnerId == uid); if (circle == null) { return NotFound();