case 4719 - report templates no longer hold the renderer's token or download key

The report page gets placeholders for the token and download key; request
interception (ReportApiRelay, now in every kind of install) puts the real
values only on the page's requests to ayApiUrl, then relays them (IIS) or
lets the browser continue. Requests to other addresses go out unchanged,
without credentials. Notification reports keep their rights. Report
scripting manual and changelog updated. F20 fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:04:55 -07:00
parent 2f616cba7a
commit a807693187
6 changed files with 85 additions and 42 deletions

View File

@@ -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<string>();
var realDownloadKey = reportRequest.ClientMeta["DownloadToken"]?.Value<string>();
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

View File

@@ -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
/// </summary>
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;
/// <summary>
@@ -93,35 +99,41 @@ namespace AyaNova.Util
}
/// <summary>
/// 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)
/// </summary>
internal static async Task<IDisposable> AttachAsync(IPage page, string apiUrl, ReportApiTarget target, TimeSpan timeout, Action<string> pageLog, ILogger log)
internal static async Task<IDisposable> AttachAsync(IPage page, string apiUrl, ReportApiTarget target, string authorization, string downloadKey, TimeSpan timeout, Action<string> 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 = "<!DOCTYPE html><html><head></head><body></body></html>" });
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<string, string>(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<string, object>(StringComparer.OrdinalIgnoreCase);
using var response = await client.SendAsync(message);
var headers = new Dictionary<string, object>(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 { }
}
};