diff --git a/README.md b/README.md index 5f41577..a14fb45 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ All mod integrations are soft dependencies via reflection — no mod DLLs are re > The default port is **8085** and binds to `0.0.0.0` (all interfaces). Both can be changed in the plugin configuration file. +### Cross-origin access (CORS) + +By default Telemachus sends no CORS headers, so browsers will block any JavaScript on a different origin (a different host, port, or scheme) from reading the responses. That covers the bundled web UI fine — it's served from the same origin as the API — but a dashboard hosted elsewhere (another machine on the LAN, a deployed static site, a dev server on a different port) needs an explicit opt-in. + +List the origins permitted to read cross-origin in `ALLOWED_ORIGINS` in `PluginData/Telemachus/config.xml`: + +```xml +http://<client-host>:<port>,https://<deployed-dashboard> +``` + +Comma-separated `scheme://host[:port]` entries — match exactly what the browser sends in the `Origin` header. Trailing slashes and empty entries are stripped. Empty list (the default) preserves the historical behaviour of never sending CORS headers, so existing setups are untouched. + +When a matching `Origin` arrives, Telemachus echoes it back in `Access-Control-Allow-Origin` (with `Vary: Origin`) rather than wildcarding, and responds 204 to the standard OPTIONS preflight. Non-allowlisted origins get no CORS header and the browser blocks the response by default. + --- ## API overview diff --git a/Telemachus/src/DataLinkResponsibility.cs b/Telemachus/src/DataLinkResponsibility.cs index 8f0d4cb..f1aeb9a 100644 --- a/Telemachus/src/DataLinkResponsibility.cs +++ b/Telemachus/src/DataLinkResponsibility.cs @@ -19,12 +19,16 @@ public class DataLinkResponsibility : IHTTPRequestResponder private UpLinkDownLinkRate dataRates = null; + /// Optional; CORS headers aren't emitted when null + private readonly ServerConfiguration serverConfig; + #region Initialisation - public DataLinkResponsibility(KSPAPIBase kspAPI, UpLinkDownLinkRate rateTracker) + public DataLinkResponsibility(KSPAPIBase kspAPI, UpLinkDownLinkRate rateTracker, ServerConfiguration serverConfig = null) { this.kspAPI = kspAPI; dataRates = rateTracker; + this.serverConfig = serverConfig; } #endregion @@ -68,10 +72,49 @@ private static IDictionary parseJSONBody(string jsonBody) return Json.DecodeObject(jsonBody); } + private bool corsConfigured => + serverConfig != null && serverConfig.AllowedOrigins != null && serverConfig.AllowedOrigins.Count > 0; + + // Whenever CORS is configured the response depends on Origin — emit Vary even on non-matches + // so shared caches don't serve a no-CORS response to a request that would have matched. + private void applyCorsHeader(HttpListenerRequest request, HttpListenerResponse response) + { + if (!corsConfigured) return; + response.Headers["Vary"] = "Origin"; + + string origin = request.Headers["Origin"]; + if (string.IsNullOrEmpty(origin)) return; + // Defence in depth: reject any Origin containing CR/LF before echoing it into a header. + if (origin.IndexOfAny(new[] { '\r', '\n' }) >= 0) return; + string normalised = origin.TrimEnd('/'); + if (!serverConfig.AllowedOrigins.Contains(normalised)) return; + response.Headers["Access-Control-Allow-Origin"] = normalised; + } + public bool process(HttpListenerRequest request, HttpListenerResponse response) { if (!request.RawUrl.StartsWith(PAGE_PREFIX)) return false; + // CORS preflight: when CORS is configured, OPTIONS never goes through the data pipeline. + // 204 with preflight headers if the Origin is allowlisted; 204 without headers otherwise, + // rather than serialising an empty JSON body. When CORS is not configured we leave OPTIONS + // behaviour unchanged from before this PR (falls through to the data pipeline below). + if (corsConfigured + && string.Equals(request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + applyCorsHeader(request, response); + if (response.Headers["Access-Control-Allow-Origin"] != null) + { + response.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; + response.Headers["Access-Control-Allow-Headers"] = "Content-Type"; + response.Headers["Access-Control-Max-Age"] = "86400"; + } + response.StatusCode = 204; + return true; + } + + applyCorsHeader(request, response); + // Work out how big this request was long byteCount = request.RawUrl.Length + request.ContentLength64; // Don't count headers + request.Headers.AllKeys.Sum(x => x.Length + request.Headers[x].Length + 1); diff --git a/Telemachus/src/ServerConfiguration.cs b/Telemachus/src/ServerConfiguration.cs index 80121ac..1f7967b 100644 --- a/Telemachus/src/ServerConfiguration.cs +++ b/Telemachus/src/ServerConfiguration.cs @@ -17,6 +17,9 @@ public class ServerConfiguration public IPAddress ipAddress { get; set; } = IPAddress.Any; /// A list of IP Addresses that the server should be accessible at public List ValidIpAddresses { get; set; } = new(); + /// Browser origins permitted to read responses cross-origin. Empty = no CORS headers sent. + /// Case-insensitive — RFC 6454 §6.1 says scheme and host are case-insensitive. + public HashSet AllowedOrigins { get; set; } = new(StringComparer.OrdinalIgnoreCase); } internal static class ServerConfigExtensions diff --git a/Telemachus/src/TelemachusBehaviour.cs b/Telemachus/src/TelemachusBehaviour.cs index 9e521c2..6b5dfae 100644 --- a/Telemachus/src/TelemachusBehaviour.cs +++ b/Telemachus/src/TelemachusBehaviour.cs @@ -69,7 +69,7 @@ static private void startDataLink() webDispatcher.AddResponder(new IOPageResponsibility()); var cameraLink = new CameraResponsibility(apiInstance, rateTracker); webDispatcher.AddResponder(cameraLink); - var dataLink = new DataLinkResponsibility(apiInstance, rateTracker); + var dataLink = new DataLinkResponsibility(apiInstance, rateTracker, serverConfig); webDispatcher.AddResponder(dataLink); var apiRoute = new APIRouteResponsibility(apiInstance, rateTracker); webDispatcher.AddResponder(apiRoute); @@ -187,6 +187,18 @@ static private void readConfiguration() isPartless = config.GetValue("PARTLESS") != 0; PluginLogger.print("Partless:" + isPartless); + + // Comma-separated browser origins permitted to read responses cross-origin. + string originsRaw = config.GetValue("ALLOWED_ORIGINS"); + if (!string.IsNullOrEmpty(originsRaw)) + { + foreach (var part in originsRaw.Split(',')) + { + var trimmed = part.Trim().TrimEnd('/'); + if (trimmed.Length > 0) serverConfig.AllowedOrigins.Add(trimmed); + } + PluginLogger.print("Allowed CORS origins: " + string.Join(", ", serverConfig.AllowedOrigins)); + } } static private void stopDataLink()