case 4712 - customer contacts limited to the routes the customer screens use

Customer contacts had the Customer role's Select and read rights on most record
types and could call any route whose staff role check they passed, including
several with no role check (security review F13 to F17). Step 1 of 4712: the
token middleware now answers 403 (same body as other 403s) to Customer and
HeadOffice users for any route not in CustomerContactRoutes, the routes the
customer screens use (read from raven-client). Anonymous routes are always
allowed. New routes stay closed to contacts unless added on purpose.
Ownership of the records these routes return follows in the next steps.

Checked: full integration suite 103 passed, 5 skipped, the same 2 known
failures as the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:44:02 -07:00
parent 7b40caec8b
commit 2b1adaa496
4 changed files with 118 additions and 6 deletions

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace AyaNova.Api.ControllerHelpers
{
/// <summary>
/// case 4712: the only API routes a customer contact (UserType Customer or HeadOffice) may call:
/// the ones the customer screens of the client use. Everything else answers 403 to a contact, so a
/// new route stays closed to contacts unless it is added here on purpose.
/// Ownership of the records these routes return is checked in the routes themselves.
/// See devdocs/modernization/SECURITY-REVIEW.md, "Customer contact route allowlist".
/// </summary>
internal static class CustomerContactRoutes
{
private const string API_PREFIX = "api/v{version:apiVersion}/";
//"METHOD route template" with the API prefix removed, exactly as in the controllers' attributes
private static readonly HashSet<string> Allowed = new HashSet<string>
{
//own account
"POST auth/change-password",
"GET auth/totp",
"POST auth/totp-validate",
"POST auth/totp-disable/{id}",
"GET user-option/{id}",
"PUT user-option/{id}",
//app shell
"GET global-biz-setting/client",
"GET enum-list/list/{enumkey}",
"GET form-custom/{formkey}",
"GET translation/list",
"POST translation/subset",
"GET server-info",
"GET notify/new-count",
"GET notify/app-notifications",
"DELETE notify/{id}",
//data lists (the server sets the customer filter for contacts)
"POST data-list",
"GET data-list/listfields",
"GET data-list-column-view/{listKey}",
"POST data-list-column-view",
"DELETE data-list-column-view/{listKey}",
"POST data-list-column-view/sort",
"GET data-list-filter/list",
"GET data-list-filter/{id}",
"POST data-list-filter",
"PUT data-list-filter",
"DELETE data-list-filter/{id}",
"GET tag-list/list",
"POST pick-list/list",
"POST pick-list/single",
//work order (customer view, customer report, attachments)
"GET workorder/customer/{id}",
"GET attachment/list",
"POST report/render-job",
"GET job-operations/status/{gid}",
"GET job-operations/logs/{gid}",
"POST report/request-cancel/{gid}",
//customer service requests (not accept / reject, which are staff actions)
"POST csr",
"PUT csr",
"GET csr/{id}",
"DELETE csr/{id}",
"POST unit/customerunit",
//notification subscriptions
"GET notify-subscription/list",
"GET notify-subscription/{id}",
"POST notify-subscription",
"PUT notify-subscription",
"DELETE notify-subscription/{id}",
"GET work-order-status/list",
"GET quote-status/list",
};
/// <summary>
/// True if a customer contact may call the endpoint this request was routed to.
/// Anonymous routes (login, downloads by key, logo...) are public anyway and always allowed.
/// </summary>
internal static bool IsAllowed(HttpContext context)
{
var endpoint = context.GetEndpoint();
if (endpoint == null)
return true;//not routed to anything: the request ends in a 404 regardless
if (endpoint.Metadata.GetMetadata<IAllowAnonymous>() != null)
return true;
if (endpoint is not RouteEndpoint routeEndpoint)
return false;
var template = routeEndpoint.RoutePattern.RawText ?? string.Empty;
if (template.StartsWith(API_PREFIX))
template = template.Substring(API_PREFIX.Length);
return Allowed.Contains($"{context.Request.Method.ToUpperInvariant()} {template}");
}
}
}

View File

@@ -458,6 +458,19 @@ namespace AyaNova
}
else
{
//case 4712: customer contacts may only call the routes the customer screens use
if ((u.UserType == UserType.Customer || u.UserType == UserType.HeadOffice) && !AyaNova.Api.ControllerHelpers.CustomerContactRoutes.IsAllowed(context))
{
context.Response.StatusCode = 403;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(Newtonsoft.Json.JsonConvert.SerializeObject(new AyaNova.Api.ControllerHelpers.ApiNotAuthorizedResponse(),
new Newtonsoft.Json.JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore
}));
return;
}
await next.Invoke();
}
}