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()