Files
raven/server/AyaNova/util/ReportApiRelay.cs
John Cardinal a807693187 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>
2026-10-25 20:04:55 -07:00

204 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using PuppeteerSharp;
namespace AyaNova.Util
{
/// <summary>
/// Where the report page's API calls are sent when AyaNova runs inside IIS: the address and port a user's request
/// arrived on, with the host name and scheme of that request
/// </summary>
internal sealed class ReportApiTarget
{
public string Scheme { get; set; }
public string Host { get; set; }//host header value, with the port when the request had one
public string Address { get; set; }
public int Port { get; set; }
internal string BaseUrl => $"{Scheme}://{Host}/api/{AyaNovaVersion.CurrentApiVersion}/";
public override string ToString() => $"{Scheme}://{Host} via {Address}:{Port}";
}
/// <summary>
/// case 4632: inside IIS (in-process hosting) AyaNova has no port of its own: nothing listens on AYANOVA_USE_URLS, and
/// the port of the user's request is IIS's, often https-only and bound to a host name. So the report page could not
/// reach the API at http://127.0.0.1:port (user reports, or notification reports at the AYANOVA_USE_URLS port), and the
/// 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 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
{
//path the page is first opened at, so it has the API's origin and its API calls are same-origin (no CORS preflight,
//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>
/// The address, port, host name and scheme this request arrived with, for relaying a report started by it
/// </summary>
internal static ReportApiTarget FromRequest(HttpContext context)
{
var address = context.Connection.LocalIpAddress;
if (address == null)
return null;
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
//the IIS server variable says if this connection is TLS; Request.Scheme may come from a proxy's X-Forwarded-Proto
var https = string.Equals(context.GetServerVariable("HTTPS"), "on", StringComparison.OrdinalIgnoreCase);
return new ReportApiTarget
{
Scheme = https ? "https" : "http",
Host = context.Request.Host.HasValue ? context.Request.Host.Value : address.ToString(),
Address = address.ToString(),
Port = context.Connection.LocalPort
};
}
/// <summary>
/// Keeps the target of the latest signed-in request, for reports rendered without one (customer notifications)
/// </summary>
internal static void Remember(HttpContext context)
{
if (!ServerBootConfig.HOSTED_IN_IIS)
return;
var target = FromRequest(context);
var last = _lastSeen;
if (target != null && (last == null || last.ToString() != target.ToString()))
_lastSeen = target;
}
/// <summary>
/// Target for a report rendered without a user request: the latest signed-in request, else the override URL
/// sent to this machine; null outside IIS or when neither is known
/// </summary>
internal static ReportApiTarget ForBackgroundRender()
{
if (!ServerBootConfig.HOSTED_IN_IIS)
return null;
if (_lastSeen != null)
return _lastSeen;
if (Uri.TryCreate(ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE?.Trim(), UriKind.Absolute, out var o))
return new ReportApiTarget { Scheme = o.Scheme, Host = o.Authority, Address = IPAddress.Loopback.ToString(), Port = o.Port };
return null;
}
/// <summary>
/// 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, string authorization, string downloadKey, TimeSpan timeout, Action<string> pageLog, ILogger log)
{
HttpClient client = null;
if (target != null)
{
var handler = new SocketsHttpHandler
{
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 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;
}
}
};
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) =>
{
var request = e.Request;
try
{
if (request.Url == pageUrl)
{
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;
}
//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 requestHeaders)
{
if (header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
continue;
if (!message.Headers.TryAddWithoutValidation(header.Key, header.Value) && message.Content != null)
{
message.Content.Headers.Remove(header.Key);
message.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
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))
continue;
headers[header.Key] = string.Join(", ", header.Value);
}
await request.RespondAsync(new ResponseData { Status = response.StatusCode, Headers = headers, BodyData = await response.Content.ReadAsByteArrayAsync() });
}
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 {where} failed: {reason}");
log.LogWarning($"Report API request {request.Method} {request.Url} via {where} failed: {reason}");
try { await request.AbortAsync(); } catch { }
}
};
await page.GoToAsync(pageUrl);
return client;
}
}
}