From 45f7f4476502a028dfb28a87b8442d39fd2da86d Mon Sep 17 00:00:00 2001 From: Jon Pepler Date: Mon, 11 May 2026 12:43:48 +0100 Subject: [PATCH 1/3] feat: add CORS allowlist for cross-origin HTTP clients Configurable via a new ALLOWED_ORIGINS setting in PluginConfiguration. Empty list (the default) preserves the historical behaviour of never sending CORS headers, so existing setups are unaffected. When configured, the server: - echoes the request's Origin in Access-Control-Allow-Origin if it matches the allowlist (with Vary: Origin), rather than emitting a wildcard - responds 204 to OPTIONS preflight with standard methods/headers - leaves non-allowlisted requests untouched, so browsers block-by-default Lets dashboards and SPAs hosted on a different origin (e.g. GitHub Pages, a local Vite dev server) read action responses without needing a same-origin proxy. --- Telemachus/src/DataLinkResponsibility.cs | 38 +++++++++++++++++++++++- Telemachus/src/ServerConfiguration.cs | 2 ++ Telemachus/src/TelemachusBehaviour.cs | 14 ++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Telemachus/src/DataLinkResponsibility.cs b/Telemachus/src/DataLinkResponsibility.cs index 8f0d4cb..3f11ad7 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,42 @@ private static IDictionary parseJSONBody(string jsonBody) return Json.DecodeObject(jsonBody); } + // Echoes the request Origin in Access-Control-Allow-Origin when it matches AllowedOrigins. + private void applyCorsHeader(HttpListenerRequest request, HttpListenerResponse response) + { + if (serverConfig == null || serverConfig.AllowedOrigins == null) return; + if (serverConfig.AllowedOrigins.Count == 0) return; + string origin = request.Headers["Origin"]; + if (string.IsNullOrEmpty(origin)) return; + string normalised = origin.TrimEnd('/'); + if (!serverConfig.AllowedOrigins.Contains(normalised)) return; + response.Headers["Access-Control-Allow-Origin"] = normalised; + response.Headers["Vary"] = "Origin"; + } + public bool process(HttpListenerRequest request, HttpListenerResponse response) { if (!request.RawUrl.StartsWith(PAGE_PREFIX)) return false; + // CORS preflight: 204 with the standard headers if Origin is allowlisted. + if (string.Equals(request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase) + && serverConfig != null + && serverConfig.AllowedOrigins != null + && serverConfig.AllowedOrigins.Count > 0) + { + 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..88c99c9 100644 --- a/Telemachus/src/ServerConfiguration.cs +++ b/Telemachus/src/ServerConfiguration.cs @@ -17,6 +17,8 @@ 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. + public List AllowedOrigins { get; set; } = new(); } 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() From 44d1252e301ed9c9efad0e9fc0f93d7420f0a472 Mon Sep 17 00:00:00 2001 From: Jon Pepler Date: Mon, 11 May 2026 15:49:28 +0100 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20README=20=E2=80=94=20document=20ALL?= =?UTF-8?q?OWED=5FORIGINS=20CORS=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 From 4e839a7833160c9d263336ff7589ab100148d02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sol=20Astrius=20Ph=C5=93nix?= Date: Mon, 11 May 2026 23:09:46 +0300 Subject: [PATCH 3/3] fix(cors): tighten allowlist matching and Vary semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Match origins case-insensitively (RFC 6454 §6.1) via HashSet with StringComparer.OrdinalIgnoreCase. Browsers normalise to lowercase before sending, but admin entries shouldn't have to. - Always send Vary: Origin when CORS is configured, including on non-matching responses. Otherwise a shared cache can serve a no-CORS response to a subsequent cross-origin request that would have matched. - Reject Origin values containing CR or LF before echoing into the Access-Control-Allow-Origin header. WebSocketSharp's header setter already validates, but defence in depth is cheap and avoids relying on library behaviour. - OPTIONS requests no longer fall through to the data pipeline when CORS is configured (a non-matching preflight previously serialised an empty JSON body); unconfigured-CORS behaviour is unchanged. --- Telemachus/src/DataLinkResponsibility.cs | 29 +++++++++++++++--------- Telemachus/src/ServerConfiguration.cs | 5 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Telemachus/src/DataLinkResponsibility.cs b/Telemachus/src/DataLinkResponsibility.cs index 3f11ad7..f1aeb9a 100644 --- a/Telemachus/src/DataLinkResponsibility.cs +++ b/Telemachus/src/DataLinkResponsibility.cs @@ -72,28 +72,35 @@ private static IDictionary parseJSONBody(string jsonBody) return Json.DecodeObject(jsonBody); } - // Echoes the request Origin in Access-Control-Allow-Origin when it matches AllowedOrigins. + 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 (serverConfig == null || serverConfig.AllowedOrigins == null) return; - if (serverConfig.AllowedOrigins.Count == 0) return; + 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; - response.Headers["Vary"] = "Origin"; } public bool process(HttpListenerRequest request, HttpListenerResponse response) { if (!request.RawUrl.StartsWith(PAGE_PREFIX)) return false; - // CORS preflight: 204 with the standard headers if Origin is allowlisted. - if (string.Equals(request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase) - && serverConfig != null - && serverConfig.AllowedOrigins != null - && serverConfig.AllowedOrigins.Count > 0) + // 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) @@ -101,9 +108,9 @@ public bool process(HttpListenerRequest request, HttpListenerResponse response) 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; } + response.StatusCode = 204; + return true; } applyCorsHeader(request, response); diff --git a/Telemachus/src/ServerConfiguration.cs b/Telemachus/src/ServerConfiguration.cs index 88c99c9..1f7967b 100644 --- a/Telemachus/src/ServerConfiguration.cs +++ b/Telemachus/src/ServerConfiguration.cs @@ -17,8 +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. - public List AllowedOrigins { 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