diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index faaaaea9..e16d6e19 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -179,7 +179,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s |---|---|---| | 4710 | 2FA step requires the temp token (raven and sockeye) | F5, F41. **Done**: raven `141bdeb8` (proof script 401 on both bypasses; integration test `TwoFactor` in `8e5eec4`); sockeye `f7c1d7c` (same checks on local Sockeye with a temporary user). Sockeye to be deployed to its new droplet by John; 2FA can be turned back on once each server runs the fix | | 4711 | JWT secret setting removed: the server generates its signing key once into `jwt-signing.key` in the data folder (4698 folded in) | F1. **Done** (placeholder-forged token 401; key survives restart; auth tests pass). Hosted setup script's secret step now does nothing; tidy later in `sockeye-client` | -| 4712 | Customer contact route allowlist + ownership checks | F13, F14, F15, F16, F17 | +| 4712 | Customer contact route allowlist + ownership checks | F13, F14, F15, F16, F17. In 6 steps: 1 allowlist **done**; 2 work-order ownership; 3 CSR and unit ownership; 4 pick lists for contacts; 5 schedule staff rights; 6 notification subscriptions | | 4713 | Contact users limited to customer roles | F28 | | 4715 | Download file names checked (one helper) | F21. **Done** (`FileUtil.FullPathInFolderOrNull` in temp/backup exists checks and both log routes; test `DownloadNames`) | | 4716 | Pick-list search text as SQL parameters | F18. **Done** (`@plq`/`@pltag` parameters; test `FetchUserPickListAutoCompleteApostrophe` fails on the old code, passes now; 9/9 pick-list tests) | diff --git a/devdocs/modernization/SECURITY-REVIEW.md b/devdocs/modernization/SECURITY-REVIEW.md index 3595b28a..d6825378 100644 --- a/devdocs/modernization/SECURITY-REVIEW.md +++ b/devdocs/modernization/SECURITY-REVIEW.md @@ -855,7 +855,7 @@ cases"): 4710 (F5, F41), 4711 (F1), 4712 (F13 to F17), 4715 (F21), 4716 (F18), 4 the AyaNova half of F39), Sockeye 4681 (F40). The Sockeye half of F39 (repeatable licence fetch) moved to a later case: AyaNova reinstalls and logs any key it fetches without comparing it with the installed one (trial servers with data would log an E1020 error on every fetch), and deployed older servers cannot be changed, so it needs an AyaNova-side change plus a request flag that Sockeye honours. Not in 8.3.0: F32, F42 (John's later cases); F37 accepted. When a case is done, its findings' status becomes *fixed in case NNNN*. -## Customer contact route allowlist (draft, for the F13 to F17 fix) +## Customer contact route allowlist (implemented in case 4712) The API routes the client can call for a customer contact (`UserType` Customer or HeadOffice), read from `raven-client` on 2026-10-23: the customer menu in `api/initialize.js` (user @@ -876,15 +876,18 @@ each id route must also check. |---|---|---| | Login, session (already anonymous or exempt) | `auth`, `auth/tfa-authenticate`, `auth/reset-password`, `notify/hello`, `translation/subset[/{id}]`, `logo/{size}`, `license/lc` (F10) | — | | Own account | `auth/change-password`, `auth/totp`, `auth/totp-validate`, `auth/totp-disable/{id}`, `user-option/{id}` GET and PUT | `{id}` = the caller (`totp-disable` already; check `user-option`) | -| App shell | `global-biz-setting/client`, `enum-list/list/{key}`, `form-custom/{formkey}`, `translation/list`, `server-info`, `notify/new-count`, `notify/app-notifications`, `DELETE notify/{id}` | `notify/{id}`: the caller's own notification (pass 7) | +| App shell | `global-biz-setting/client`, `enum-list/list/{key}`, `form-custom/{formkey}`, `translation/list`, `translation/subset` (logged-in version, used to load translations), `server-info`, `notify/new-count`, `notify/app-notifications`, `DELETE notify/{id}` | `notify/{id}`: the caller's own notification (pass 7) | | Data lists (work orders, CSRs) | `data-list` (server already forces the customer filter), `data-list/listfields`, `data-list-column-view…`, `data-list-filter…`, `tag-list/list`, `pick-list/list` from the filter editor | saved filters: already scoped to the user | | Work order (customer view) | `workorder/customer/{id}`, `attachment/list` (work order only), `attachment/download/{id}` (download key), `report/render-job` with report `-100` only, `job-operations/status|logs/{gid}`, `report/request-cancel/{gid}`, `report/download/{file}` | work order belongs to the contact's customer (or a customer of its head office): **F13**; job and report file belong to the caller (pass 4) | | Service requests | `csr` POST/PUT, `csr/{id}` GET/DELETE (not `accept`/`reject`), `unit/customerunit` POST, `pick-list/list` and `pick-list/single` for Customer (head-office variant), Unit (own customer) and UnitModel only, with the server's own template | CSR and unit belong to the contact's customer; `CustomerId` forced on create: **F14**, **F15** | | Notification subscriptions | `notify-subscription/list`, `notify-subscription/{id}` GET/DELETE, `notify-subscription` POST/PUT, `work-order-status/list`, `quote-status/list` (names only, used by the editor) | `UserId` forced to the caller: **F17** | -To confirm while fixing: whether the `open-object-handler` routes (`attachment/parent`, -`form-custom/form-key`, `user/inside-type`, `search/ancestor`) are used when a customer opens -a notification; whether `server-info` (about page) must stay open to contacts. +Confirmed while fixing (case 4712): for customer users the client's open-object handler only navigates +to the customer screens, so its staff routes (`attachment/parent`, `form-custom/form-key`, +`user/inside-type`, `search/ancestor`) are not needed; `server-info` stays open (the About page calls it +for everyone; F29 trims what it returns). The list lives in +`server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs`; the token middleware answers 403 to a +contact for anything else (also closes F8's suffix exemption for contacts). **Shape of the fix.** One check in the token middleware, which already loads `UserType` for every request: for Customer and HeadOffice users, compare the matched endpoint's route diff --git a/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs b/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs new file mode 100644 index 00000000..3f684eab --- /dev/null +++ b/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace AyaNova.Api.ControllerHelpers +{ + /// + /// 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". + /// + 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 Allowed = new HashSet + { + //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", + }; + + /// + /// 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. + /// + 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() != 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}"); + } + } +} diff --git a/server/AyaNova/Startup.cs b/server/AyaNova/Startup.cs index cbc3331d..390157d6 100644 --- a/server/AyaNova/Startup.cs +++ b/server/AyaNova/Startup.cs @@ -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(); } }