diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index c3f69625..05b076e2 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -186,7 +186,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s | 4716 | Pick-list search text as SQL parameters | F18, and F43 (unit pick-list variant parsed as a number, found during 4712, test `FetchUnitPickListCustomerVariant`). **Done** (`@plq`/`@pltag` parameters; test `FetchUserPickListAutoCompleteApostrophe` fails on the old code, passes now; 9/9 pick-list tests) | | 4717 | KPI interval allowlist | F26. **Done** (`KPIFetcher.DateTruncUnitOrNull` in the 7 KPIs reading `interval`; test `KpiInterval`) | | 4718 | Report and translation import need create rights | F19, F24. **Done** (contact and Service 403, BizAdmin 202; test `ImportRights`) | -| 4719 | Report page network restricted; notification reports not run as SuperUser | F20 | +| 4719 | Report page network restricted; notification reports not run as SuperUser | F20. **Done**, plan changed with John: 1 report templates changeable only by BizAdmin (`2f616cba`, test `ReportEditRights`); 2 the page holds placeholders for the token and download key, the server adds the real ones only on requests to `ayApiUrl` (interception in every kind of install; test `ReportRenderCredentials`; 22 stock reports unchanged locally; IIS VM user and notification reports); outside addresses not blocked (customer templates may load their own content), notification reports keep their rights (customers write from reports). Visible: templates calling the API by another address need changing (manual and changelog); attachment links printed into reports carry no download key (manual only, John: such links pointed at 127.0.0.1 unless the override was set, so rarely worked) | | 4720 | Restricted roles filtered in report/export data | F27. **Done** (partial fetch filtered like the full one; BizObjectFactory passed UserType.Service for everyone, now the real type; test `RestrictedReportData`) | | 4721 | Client escaping of record text | F33. **Done** (raven-client: `escapeHtml` helper for the warranty summary; address dialog as text; search excerpt escaped with the terms highlighted as plain text; dialog messages through DOMPurify. The excerpt is not encoded on the server as well: the client escaping covers it. John checked search, warranty info with `` in a vendor name, the address dialog and confirmation dialogs) | | 4722 | DOMPurify and Vuetify upgrades (raven-client) | F34. **Done** (raven-client `44411ec1`: DOMPurify 2.5.9, Vuetify 2.7.2, builds on Node 12.22.9; Vuetify 2.7's calendar `eventSummary()` returns an element, so the six calendar event templates render it with a new `gz-vnodes` component instead of `v-html`. John checked wiki, schedules (drag, stretch, lock icons), date pickers, lists, dialogs and the reminder and review widgets. John also checked the scheduled work order widget as a Tech user. Still to do: Cypress on `devtestn10` at the final gate) | diff --git a/devdocs/modernization/SECURITY-REVIEW.md b/devdocs/modernization/SECURITY-REVIEW.md index dc541eb0..0364e584 100644 --- a/devdocs/modernization/SECURITY-REVIEW.md +++ b/devdocs/modernization/SECURITY-REVIEW.md @@ -203,7 +203,7 @@ take the next free number; a fixed or accepted finding keeps its ID and changes | F15 | High | **fixed in case 4712** | Customer contacts can list every customer (and vendors, users, parts...) with any pick-list columns, phones and emails included | | F16 | High | **fixed in case 4712** | `schedule/svc` and `schedule/user` have no role check: anyone logged in sees the whole service schedule | | F17 | Medium | **fixed in case 4712** | Notification subscriptions accept any `UserId`, and object events reach subscribers without a rights check: record names to any email address | -| F20 | High | candidate | Report templates receive the renderer's token (SuperUser for customer notification reports) and the report browser can reach any network address | +| F20 | High | **fixed in case 4719** | Report templates receive the renderer's token (SuperUser for customer notification reports) and the report browser can reach any network address | | F27 | High | **fixed in case 4720** | Restricted roles (TechRestricted, SubContractor, SubContractorRestricted) get whole work-order items through report data, rendering and export | | F28 | High | **fixed in case 4713** | Users with change rights on Customer (Service, Sales, Accounting) can give customer contact logins any role, BizAdmin and OpsAdmin included | | F33 | High | **fixed in case 4721** | Client renders some record text as HTML without escaping (search results, work-order address dialog, unit warranty info, dialogs) | @@ -489,6 +489,8 @@ runs the report. Fix options: intercept page requests (PuppeteerSharp notification reports as a dedicated low-rights identity rather than SuperUser; consider a short-lived token limited to read routes for template API calls. +**Fixed in case 4719 (2026-10-25).** The report page holds only placeholders for the renderer's token and download key (`Bearer ay-report-render-token`, `ay-report-render-download-key`); request interception (`util/ReportApiRelay.cs`, from case 4632, now in every kind of install) puts the real values only on the page's requests to `ayApiUrl`, so a template that sends them elsewhere sends a placeholder (stock template "z_user logged in ... ayClientMetaData" printed the real token and download key into the PDF until now). Report templates are changeable only by BizAdmin (BizAdminRestricted had change rights, against the roles page), so only someone who can already change users' roles authors what runs with other people's rights, including SuperUser for notification reports, which keep their rights (customers write from reports). Outside addresses stay reachable (customer templates may load their own images or fonts); a template author can still send out data they can read, which is no escalation. Tests `ReportEditRights`, `ReportRenderCredentials` (before: the page saw the real token and download key); 22 stock reports unchanged locally; IIS VM user and notification reports. + ### F21 File names from the URL in download routes (pass 4, candidate, code reading) `report/download/{fileName}` and `export/download/{fileName}` accept any user's download key diff --git a/docs/8.0/ayanova/docs/ay-report-edit.md b/docs/8.0/ayanova/docs/ay-report-edit.md index 5b260810..7a1054c6 100644 --- a/docs/8.0/ayanova/docs/ay-report-edit.md +++ b/docs/8.0/ayanova/docs/ay-report-edit.md @@ -304,8 +304,8 @@ ayClientMetaData: { "UserName": "AyaNova username", "UserId": "23", - "Authorization": "Bearer eJhbGciO...token...data...hb9JyjcWl3Tib", - "DownloadToken": "IslXKU....token....data...here....Bg2Vx0yT2rQ", + "Authorization": "Bearer ay-report-render-token", + "DownloadToken": "ay-report-render-download-key", "TimeZoneName": "America/Los_Angeles", "LanguageName": "en-US", "Hour12": true, @@ -351,7 +351,7 @@ ayServerMetaData: } ``` -- ayApiUrl - this is the URL of the server relative to the report generator and is useful for calling AyaNova API methods in your prepare data function to bring in sources of information from other areas of AyaNova that might not be directly available to your report +- ayApiUrl - this is the URL of the server relative to the report generator and is useful for calling AyaNova API methods in your prepare data function to bring in sources of information from other areas of AyaNova that might not be directly available to your report. Always call the AyaNova API through this URL (or the API convenience functions below, which use it): the User's access is only added to requests made to this URL (see [API Usage](#api-usage)) - HasXXLogo - these keys are set to true if a Logo has been uploaded for each size to AyaNova and are useful for controlling whether or not to show a logo on a report template as can be seen in many of our sample reports - Company XXX fields - these fields represent your company information as entered in Global settings to provide a centralized source of information for all reports requiring company contact or address data @@ -559,9 +559,16 @@ The `ayGroupByKey` function does no sorting so the data will be returned in the The full power of the [AyaNova developer's API](api-intro.md) is available to reports to incorporate data from anywhere in AyaNova as required. -The currently logged in User's API Bearer access token is provided for accessing API routes via the `ayClientMetaData.Authorization` value (see above). +API calls from a report run with the access of the User running the report, so a report can only do what that User could also do in the AyaNova application. Reports attached to [customer notifications](customer-notify-subscriptions.md) run with full administrative access. -You should not store a static copy of an API Bearer access token or hard code it into your templates because it changes regularly and will expire the moment the User logs in to AyaNova. +The report itself never holds the User's actual credentials. The `ayClientMetaData.Authorization` and `ayClientMetaData.DownloadToken` values are stand-ins, and the AyaNova server puts the User's actual access in their place only on requests made to the `ayApiUrl` address. So API calls and attachment images work as usual through `ayApiUrl`, but: + +- an API call to any other address, including your AyaNova server's own public address typed into a template, is made without the User's access and will be refused; use `ayApiUrl` instead +- a link to an attachment printed in the finished report (for example from a wiki) does not include a download key and will not open the attachment + +Because a report template's script runs with the access of whoever runs the report, only Users with the full "Business administration" role can create or edit report templates. + +You should not store a static copy of an API Bearer access token or hard code it into your templates. While it is possible to login via a script using alternate credentials and access the API with alternative access to the current logged in user, this opens a security hole as you would need to hard code credentials into the report script and for this reason is **absolutely NOT recommended**. diff --git a/docs/8.0/ayanova/docs/changelog.md b/docs/8.0/ayanova/docs/changelog.md index 278a4e2c..016e72f1 100644 --- a/docs/8.0/ayanova/docs/changelog.md +++ b/docs/8.0/ayanova/docs/changelog.md @@ -29,6 +29,7 @@ IN PROGRESS - Server: AyaNova now checks the email (SMTP) server's security certificate when sending email. If your mail server uses a self-signed or internal certificate and email notifications stop after upgrading, see [SMTP certificate check](ops-config-smtp-accept-any-certificate.md). - Server: after repeated failed login attempts, further attempts for that login, or from that address, are refused for a few minutes. - Server: new [trusted reverse proxies](ops-config-trusted-proxies.md) setting for a reverse proxy that runs on a different computer from the AyaNova server, so AyaNova sees each user's own address. +- Server: while a report renders, its template no longer holds the User's actual access token or download key; the AyaNova server adds the User's access only to the report's requests to the `ayApiUrl` address. Templates that call the AyaNova API through any other address need to be changed. See [API usage in reports](ay-report-edit.md#api-usage). - Server: users with the "Business administration - restricted" role can view [report templates](adm-report-templates.md) but no longer create, edit or import them, as described for that [role](ay-biz-admin-roles.md). Give the full "Business administration" role to users who edit report templates. - Server: the server information page at `/api/v8/` no longer shows license details. - Server: business logo uploads must be PNG, JPEG, GIF, WebP or SVG images, and need full rights to global settings. See [business logos](adm-global-logo.md). diff --git a/server/AyaNova/biz/ReportBiz.cs b/server/AyaNova/biz/ReportBiz.cs index 553ffd91..bc034135 100644 --- a/server/AyaNova/biz/ReportBiz.cs +++ b/server/AyaNova/biz/ReportBiz.cs @@ -951,12 +951,18 @@ What could be the issue here? } }; - //case 4632: inside IIS the page's API calls are relayed by the server, see ReportApiRelay - if (relayTarget != null) - { - log.LogDebug($"Relaying report API calls under {apiUrl} to {relayTarget}"); - relayClient = await ReportApiRelay.AttachAsync(page, apiUrl, relayTarget, TimeSpan.FromMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), s => PageLog.AppendLine(s), log); - } + //case 4719: the page gets placeholders for the token and download key, the real ones are only added to its + //requests to the API URL (a template's script could send them anywhere) + var realAuthorization = reportRequest.ClientMeta["Authorization"]?.Value(); + var realDownloadKey = reportRequest.ClientMeta["DownloadToken"]?.Value(); + var pageClientMeta = reportRequest.ClientMeta.DeepClone(); + if (realAuthorization != null) + pageClientMeta["Authorization"] = "Bearer " + ReportApiRelay.TOKEN_PLACEHOLDER; + if (realDownloadKey != null) + pageClientMeta["DownloadToken"] = ReportApiRelay.DOWNLOAD_KEY_PLACEHOLDER; + //case 4632: inside IIS the page's API calls are also relayed by the server + log.LogDebug($"Handling report API calls under {apiUrl}{(relayTarget == null ? "" : $", relayed to {relayTarget}")}"); + relayClient = await ReportApiRelay.AttachAsync(page, apiUrl, relayTarget, realAuthorization, realDownloadKey, TimeSpan.FromMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), s => PageLog.AppendLine(s), log); log.LogDebug($"Preparing page: adding base reporting scripts to page"); if (!ReportRenderManager.KeepGoing(job.GId)) @@ -1023,7 +1029,7 @@ What could be the issue here? log.LogDebug($"Preparing page: adding Client meta data"); //Client meta data to JSON string - var clientMeta = reportRequest.ClientMeta.ToString(); + var clientMeta = pageClientMeta.ToString(); log.LogDebug($"Preparing page: adding Server meta data"); //Server meta data diff --git a/server/AyaNova/util/ReportApiRelay.cs b/server/AyaNova/util/ReportApiRelay.cs index 32b1f696..2f822267 100644 --- a/server/AyaNova/util/ReportApiRelay.cs +++ b/server/AyaNova/util/ReportApiRelay.cs @@ -33,7 +33,9 @@ namespace AyaNova.Util /// override setting only helped where the server can reach itself by its public name. Under IIS the report page's /// requests to the API URL are intercepted and made here instead, to the local address and port a user's request /// arrived on, with that request's host name and scheme: the IIS binding and certificate match, and no DNS or router - /// is involved. Outside IIS nothing changes (the page calls http://127.0.0.1:port itself, which works with Kestrel) + /// is involved. Outside IIS the request goes on from the browser as before. + /// case 4719: in every kind of install the page holds only placeholders for the renderer's token and download key (a + /// template's script could send the real ones anywhere); the real values are put in here, only on requests to the API URL /// internal static class ReportApiRelay { @@ -41,6 +43,10 @@ namespace AyaNova.Util //which the browser would send itself, past the interception) private const string PAGE_PATH = "ay-report-render-page"; + //case 4719: what the page sees instead of the renderer's token and download key + internal const string TOKEN_PLACEHOLDER = "ay-report-render-token"; + internal const string DOWNLOAD_KEY_PLACEHOLDER = "ay-report-render-download-key"; + private static volatile ReportApiTarget _lastSeen; /// @@ -93,35 +99,41 @@ namespace AyaNova.Util } /// - /// Opens the page at the API URL's origin and relays the page's requests under the API URL to the target. - /// Returns the HTTP client to dispose when the page is done + /// Opens the page at the API URL's origin and handles the page's requests under the API URL: puts the real token + /// and download key in place of the placeholders, then relays the request to the target (inside IIS) or lets the + /// browser make it (target null). Returns the relay's HTTP client to dispose when the page is done (null without a target) /// - internal static async Task AttachAsync(IPage page, string apiUrl, ReportApiTarget target, TimeSpan timeout, Action pageLog, ILogger log) + internal static async Task AttachAsync(IPage page, string apiUrl, ReportApiTarget target, string authorization, string downloadKey, TimeSpan timeout, Action pageLog, ILogger log) { - var handler = new SocketsHttpHandler + HttpClient client = null; + if (target != null) { - AllowAutoRedirect = false, - UseCookies = false, - UseProxy = false, - //always the local address, whatever the URL's host name resolves to; TLS still checks the certificate for that name - ConnectCallback = async (context, cancel) => + var handler = new SocketsHttpHandler { - var address = IPAddress.Parse(target.Address); - var socket = new Socket(address.AddressFamily, SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp) { NoDelay = true }; - try + AllowAutoRedirect = false, + UseCookies = false, + UseProxy = false, + //always the local address, whatever the URL's host name resolves to; TLS still checks the certificate for that name + ConnectCallback = async (context, cancel) => { - await socket.ConnectAsync(new IPEndPoint(address, target.Port), cancel); - return new NetworkStream(socket, ownsSocket: true); + var address = IPAddress.Parse(target.Address); + var socket = new Socket(address.AddressFamily, SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(new IPEndPoint(address, target.Port), cancel); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } } - catch - { - socket.Dispose(); - throw; - } - } - }; - var client = new HttpClient(handler) { Timeout = timeout }; + }; + client = new HttpClient(handler) { Timeout = timeout }; + } var pageUrl = apiUrl + PAGE_PATH; + var where = target == null ? "browser" : target.ToString(); await page.SetRequestInterceptionAsync(true); page.Request += async (sender, e) => @@ -134,15 +146,29 @@ namespace AyaNova.Util await request.RespondAsync(new ResponseData { Status = HttpStatusCode.OK, ContentType = "text/html", Body = "" }); return; } + //anywhere else as it is, without credentials (a template may load its own images, fonts or scripts) if (!request.Url.StartsWith(apiUrl, StringComparison.OrdinalIgnoreCase)) { await request.ContinueAsync(); return; } - using var message = new HttpRequestMessage(request.Method, target.BaseUrl + request.Url.Substring(apiUrl.Length)); + + //case 4719: the real token and download key, only here + var url = downloadKey == null ? request.Url : request.Url.Replace("t=" + DOWNLOAD_KEY_PLACEHOLDER, "t=" + downloadKey); + var requestHeaders = new Dictionary(request.Headers, StringComparer.OrdinalIgnoreCase); + if (authorization != null && requestHeaders.TryGetValue("Authorization", out var auth) && auth.Contains(TOKEN_PLACEHOLDER)) + requestHeaders["Authorization"] = authorization; + + if (client == null) + { + await request.ContinueAsync(new Payload { Url = url, Headers = requestHeaders }); + return; + } + + using var message = new HttpRequestMessage(request.Method, target.BaseUrl + url.Substring(apiUrl.Length)); if (request.HasPostData) message.Content = new StringContent(request.PostData ?? await request.FetchPostDataAsync() ?? string.Empty); - foreach (var header in request.Headers) + foreach (var header in requestHeaders) { if (header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) continue; @@ -152,7 +178,8 @@ namespace AyaNova.Util message.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); } } - using var response = await client.SendAsync(message); var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var response = await client.SendAsync(message); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var header in response.Headers.Concat(response.Content.Headers)) { if (header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) @@ -164,8 +191,8 @@ namespace AyaNova.Util catch (Exception ex) { var reason = ex.InnerException != null ? $"{ex.Message} ({ex.InnerException.Message})" : ex.Message; - pageLog($"ERROR: AyaNova API request {request.Method} {request.Url} via {target} failed: {reason}"); - log.LogWarning($"Report API request {request.Method} {request.Url} via {target} failed: {reason}"); + pageLog($"ERROR: AyaNova API request {request.Method} {request.Url} via {where} failed: {reason}"); + log.LogWarning($"Report API request {request.Method} {request.Url} via {where} failed: {reason}"); try { await request.AbortAsync(); } catch { } } };