Skip to content
Draft
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
17 changes: 11 additions & 6 deletions docs/api.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
function generateExamples(endpoint, method, body = null) {
let curlBodyString = '';
let curlHeaderString = '';
let psBodyString = '';
let psContentTypeString = '';
let psBodyParams = '';

if (body) {
const curlJsonString = JSON.stringify(body).replace(/"/g, '\\"');
curlBodyString = ` -d "${curlJsonString}"`;
curlHeaderString = ' -H "Content-Type: application/json"';
psBodyString = `-Body (ConvertTo-Json ${JSON.stringify(body)})`;
psContentTypeString = '-ContentType \'application/json\'';
psBodyParams = ' `\n ' + psBodyString + ' `\n ' + psContentTypeString;
}

return {
cURL: `curl -u user:pass -H "Content-Type: application/json" -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${curlBodyString}`,
cURL: `curl -u user:pass${curlHeaderString} -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${curlBodyString}`,
Python: `import json
import requests
from requests.auth import HTTPBasicAuth
Expand All @@ -22,19 +28,18 @@ requests.${method.trim().toLowerCase()}(
JavaScript: `fetch('https://localhost:47990${endpoint.trim()}', {
method: '${method.trim()}',
headers: {
'Authorization': 'Basic ' + btoa('user:pass'),
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa('user:pass'),${body ? `\n 'Content-Type': 'application/json',` : ''}
}${body ? `,\n body: JSON.stringify(${JSON.stringify(body)}),` : ''}
})
.then(response => response.json())
.then(data => console.log(data));`,
PowerShell: `Invoke-RestMethod \`
-SkipCertificateCheck \`
-ContentType 'application/json' \`
-Uri 'https://localhost:47990${endpoint.trim()}' \`
-Method ${method.trim()} \`
-Headers @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes('user:pass'))}
${psBodyString}`
-Headers @{
Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes('user:pass'))
}${psBodyParams}`
};
}

Expand Down
28 changes: 28 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,38 @@ Sunshine has a RESTful API which can be used to interact with the service.
Unless otherwise specified, authentication is required for all API calls. You can authenticate using
basic authentication with the admin username and password.

## CSRF Protection

State-changing API endpoints (POST, DELETE) are protected against Cross-Site Request Forgery (CSRF) attacks.

**For Web Browsers:**
- Requests from same-origin (configured via `csrf_allowed_origins`) are automatically allowed
- Cross-origin requests require a CSRF token

**For Non-Browser Applications:**
- Applications making requests from the same origin configured in `csrf_allowed_origins` do NOT need CSRF tokens
- The `Origin` or `Referer` header is automatically checked
- If your application is making requests from a different origin, you need to:
1. Get a CSRF token from `GET /api/csrf-token`
2. Include it in requests via `X-CSRF-Token` header or `csrf_token` query parameter

**Example:**
```bash
# Get CSRF token (if needed)
curl -u user:pass https://localhost:47990/api/csrf-token

# Use token in request
curl -u user:pass -H "X-CSRF-Token: your_token_here" \
-X POST https://localhost:47990/api/restart
```

@htmlonly
<script src="api.js"></script>
@endhtmlonly

## GET /api/csrf-token
@copydoc confighttp::getCSRFToken()

## GET /api/apps
@copydoc confighttp::getApps()

Expand Down
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,35 @@ editing the `conf` file in a text editor. Use the examples as reference.
</tr>
</table>

### csrf_allowed_origins

<table>
<tr>
<td>Description</td>
<td colspan="2">
Comma-separated list of additional allowed origins for CSRF protection. These origins will be
appended to the default allowed origins (localhost variants and the configured web UI port).
Requests from allowed origins can access state-changing API endpoints without CSRF tokens.
<br><br>
@attention{Only add origins you trust. Each origin must be a complete URL prefix
including protocol and host (e.g., https://example.com). Port numbers are optional.}
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
(empty - uses built-in defaults: https://localhost, https://127.0.0.1, https://[::1],
with configured UI port variants)
@endcode</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
csrf_allowed_origins = https://myapp.local,https://custom.domain.com
@endcode</td>
</tr>
</table>

### external_ip

<table>
Expand Down
47 changes: 47 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// standard includes
#include <algorithm>
#include <filesystem>
#include <format>
#include <fstream>
#include <functional>
#include <iostream>
Expand Down Expand Up @@ -725,6 +726,27 @@ namespace config {
}
}

void string_list_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, std::vector<std::string> &input) {
std::string temp;
string_f(vars, name, temp);

if (temp.empty()) {
return;
}

input.clear();
std::stringstream ss(temp);
std::string item;
while (std::getline(ss, item, ',')) {
// Trim whitespace
item.erase(0, item.find_first_not_of(" \t\r\n"));
item.erase(item.find_last_not_of(" \t\r\n") + 1);
if (!item.empty()) {
input.push_back(item);
}
}
}

void path_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, fs::path &input) {
// appdata needs to be retrieved once only
static auto appdata = platf::appdata();
Expand Down Expand Up @@ -1165,6 +1187,24 @@ namespace config {

string_restricted_f(vars, "origin_web_ui_allowed", nvhttp.origin_web_ui_allowed, {"pc"sv, "lan"sv, "wan"sv});

// Parse CSRF allowed origins - always include defaults, then append user-configured origins
std::vector<std::string> user_csrf_origins;
string_list_f(vars, "csrf_allowed_origins", user_csrf_origins);

// Start with default localhost variants
sunshine.csrf_allowed_origins = {
"https://localhost",
"https://127.0.0.1",
"https://[::1]"
};

// Append user-configured origins
sunshine.csrf_allowed_origins.insert(
sunshine.csrf_allowed_origins.end(),
user_csrf_origins.begin(),
user_csrf_origins.end()
);

int to = -1;
int_between_f(vars, "ping_timeout", to, {-1, std::numeric_limits<int>::max()});
if (to != -1) {
Expand Down Expand Up @@ -1242,6 +1282,13 @@ namespace config {
int_between_f(vars, "port"s, port, {1024 + nvhttp::PORT_HTTPS, 65535 - rtsp_stream::RTSP_SETUP_PORT});
sunshine.port = (std::uint16_t) port;

// Now that we have the port, add web UI port-specific origins to CSRF allowed list
// Web UI runs on port + 1 (PORT_HTTPS offset is 1 for confighttp)
const unsigned short web_ui_port = sunshine.port + 1;
sunshine.csrf_allowed_origins.push_back(std::format("https://localhost:{}", web_ui_port));
sunshine.csrf_allowed_origins.push_back(std::format("https://127.0.0.1:{}", web_ui_port));
sunshine.csrf_allowed_origins.push_back(std::format("https://[::1]:{}", web_ui_port));

string_restricted_f(vars, "address_family", sunshine.address_family, {"ipv4"sv, "both"sv});
string_f(vars, "bind_address", sunshine.bind_address);

Expand Down
4 changes: 4 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ namespace config {
bool notify_pre_releases;
bool system_tray;
std::vector<prep_cmd_t> prep_cmds;

// List of allowed origins for CSRF protection (e.g., "https://example.com,https://app.example.com")
// Comma-separated list of additional origins. Default includes localhost variants and web UI port.
std::vector<std::string> csrf_allowed_origins;
};

extern video_t video;
Expand Down
Loading
Loading