Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<string name="ALLOWED_ORIGINS">http://&lt;client-host&gt;:&lt;port&gt;,https://&lt;deployed-dashboard&gt;</string>
```

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
Expand Down
45 changes: 44 additions & 1 deletion Telemachus/src/DataLinkResponsibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,10 +72,49 @@ private static IDictionary<string, object> 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);
Expand Down
3 changes: 3 additions & 0 deletions Telemachus/src/ServerConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public class ServerConfiguration
public IPAddress ipAddress { get; set; } = IPAddress.Any;
/// <summary>A list of IP Addresses that the server should be accessible at</summary>
public List<IPAddress> ValidIpAddresses { get; set; } = new();
/// <summary>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.</summary>
public HashSet<string> AllowedOrigins { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

internal static class ServerConfigExtensions
Expand Down
14 changes: 13 additions & 1 deletion Telemachus/src/TelemachusBehaviour.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -187,6 +187,18 @@ static private void readConfiguration()

isPartless = config.GetValue<int>("PARTLESS") != 0;
PluginLogger.print("Partless:" + isPartless);

// Comma-separated browser origins permitted to read responses cross-origin.
string originsRaw = config.GetValue<string>("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()
Expand Down
Loading