Skip to content

Commit b1252cf

Browse files
Add CSRF protection and token endpoint
Implement CSRF protection across HTTP API endpoints and expose a token endpoint. Changes include: - Add docs: API and configuration docs updated to describe CSRF protection and the new GET /api/csrf-token endpoint. - Config: add csrf_allowed_origins to config struct; parse comma-separated origin lists; include built-in localhost defaults and append web UI port-specific origins once port is known. - confighttp: implement CSRF token generation, storage (with expiration), client identification, and validation logic. Validation allows same-origin requests via Origin/Referer to bypass tokens and requires X-CSRF-Token header or csrf_token query param for cross-origin requests. Register GET /api/csrf-token and integrate validation into state-changing endpoints. - Web UI: add form field and localization strings for csrf_allowed_origins and include it in config HTML. - Tests: add unit tests for CSRF token generation, header/query validation, same-origin exemptions, and restore/cleanup of config state. Also remove usages of the old empty-body checker where CSRF/authentication flow was applied. This commit wires CSRF protection end-to-end (docs, config, server, UI, and tests).
1 parent cb500b9 commit b1252cf

File tree

10 files changed

+508
-67
lines changed

10 files changed

+508
-67
lines changed

docs/api.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,38 @@ Sunshine has a RESTful API which can be used to interact with the service.
55
Unless otherwise specified, authentication is required for all API calls. You can authenticate using
66
basic authentication with the admin username and password.
77

8+
## CSRF Protection
9+
10+
State-changing API endpoints (POST, DELETE) are protected against Cross-Site Request Forgery (CSRF) attacks.
11+
12+
**For Web Browsers:**
13+
- Requests from same-origin (configured via `csrf_allowed_origins`) are automatically allowed
14+
- Cross-origin requests require a CSRF token
15+
16+
**For Non-Browser Applications:**
17+
- Applications making requests from the same origin configured in `csrf_allowed_origins` do NOT need CSRF tokens
18+
- The `Origin` or `Referer` header is automatically checked
19+
- If your application is making requests from a different origin, you need to:
20+
1. Get a CSRF token from `GET /api/csrf-token`
21+
2. Include it in requests via `X-CSRF-Token` header or `csrf_token` query parameter
22+
23+
**Example:**
24+
```bash
25+
# Get CSRF token (if needed)
26+
curl -u user:pass https://localhost:47990/api/csrf-token
27+
28+
# Use token in request
29+
curl -u user:pass -H "X-CSRF-Token: your_token_here" \
30+
-X POST https://localhost:47990/api/restart
31+
```
32+
833
@htmlonly
934
<script src="api.js"></script>
1035
@endhtmlonly
1136

37+
## GET /api/csrf-token
38+
@copydoc confighttp::getCSRFToken()
39+
1240
## GET /api/apps
1341
@copydoc confighttp::getApps()
1442

docs/configuration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,35 @@ editing the `conf` file in a text editor. Use the examples as reference.
16061606
</tr>
16071607
</table>
16081608

1609+
### csrf_allowed_origins
1610+
1611+
<table>
1612+
<tr>
1613+
<td>Description</td>
1614+
<td colspan="2">
1615+
Comma-separated list of additional allowed origins for CSRF protection. These origins will be
1616+
appended to the default allowed origins (localhost variants and the configured web UI port).
1617+
Requests from allowed origins can access state-changing API endpoints without CSRF tokens.
1618+
<br><br>
1619+
@attention{Only add origins you trust. Each origin must be a complete URL prefix
1620+
including protocol and host (e.g., https://example.com). Port numbers are optional.}
1621+
</td>
1622+
</tr>
1623+
<tr>
1624+
<td>Default</td>
1625+
<td colspan="2">@code{}
1626+
(empty - uses built-in defaults: https://localhost, https://127.0.0.1, https://[::1],
1627+
with configured UI port variants)
1628+
@endcode</td>
1629+
</tr>
1630+
<tr>
1631+
<td>Example</td>
1632+
<td colspan="2">@code{}
1633+
csrf_allowed_origins = https://myapp.local,https://custom.domain.com
1634+
@endcode</td>
1635+
</tr>
1636+
</table>
1637+
16091638
### external_ip
16101639

16111640
<table>

src/config.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// standard includes
66
#include <algorithm>
77
#include <filesystem>
8+
#include <format>
89
#include <fstream>
910
#include <functional>
1011
#include <iostream>
@@ -725,6 +726,27 @@ namespace config {
725726
}
726727
}
727728

729+
void string_list_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, std::vector<std::string> &input) {
730+
std::string temp;
731+
string_f(vars, name, temp);
732+
733+
if (temp.empty()) {
734+
return;
735+
}
736+
737+
input.clear();
738+
std::stringstream ss(temp);
739+
std::string item;
740+
while (std::getline(ss, item, ',')) {
741+
// Trim whitespace
742+
item.erase(0, item.find_first_not_of(" \t\r\n"));
743+
item.erase(item.find_last_not_of(" \t\r\n") + 1);
744+
if (!item.empty()) {
745+
input.push_back(item);
746+
}
747+
}
748+
}
749+
728750
void path_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, fs::path &input) {
729751
// appdata needs to be retrieved once only
730752
static auto appdata = platf::appdata();
@@ -1165,6 +1187,24 @@ namespace config {
11651187

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

1190+
// Parse CSRF allowed origins - always include defaults, then append user-configured origins
1191+
std::vector<std::string> user_csrf_origins;
1192+
string_list_f(vars, "csrf_allowed_origins", user_csrf_origins);
1193+
1194+
// Start with default localhost variants
1195+
sunshine.csrf_allowed_origins = {
1196+
"https://localhost",
1197+
"https://127.0.0.1",
1198+
"https://[::1]"
1199+
};
1200+
1201+
// Append user-configured origins
1202+
sunshine.csrf_allowed_origins.insert(
1203+
sunshine.csrf_allowed_origins.end(),
1204+
user_csrf_origins.begin(),
1205+
user_csrf_origins.end()
1206+
);
1207+
11681208
int to = -1;
11691209
int_between_f(vars, "ping_timeout", to, {-1, std::numeric_limits<int>::max()});
11701210
if (to != -1) {
@@ -1242,6 +1282,13 @@ namespace config {
12421282
int_between_f(vars, "port"s, port, {1024 + nvhttp::PORT_HTTPS, 65535 - rtsp_stream::RTSP_SETUP_PORT});
12431283
sunshine.port = (std::uint16_t) port;
12441284

1285+
// Now that we have the port, add web UI port-specific origins to CSRF allowed list
1286+
// Web UI runs on port + 1 (PORT_HTTPS offset is 1 for confighttp)
1287+
const unsigned short web_ui_port = sunshine.port + 1;
1288+
sunshine.csrf_allowed_origins.push_back(std::format("https://localhost:{}", web_ui_port));
1289+
sunshine.csrf_allowed_origins.push_back(std::format("https://127.0.0.1:{}", web_ui_port));
1290+
sunshine.csrf_allowed_origins.push_back(std::format("https://[::1]:{}", web_ui_port));
1291+
12451292
string_restricted_f(vars, "address_family", sunshine.address_family, {"ipv4"sv, "both"sv});
12461293
string_f(vars, "bind_address", sunshine.bind_address);
12471294

src/config.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ namespace config {
259259
bool notify_pre_releases;
260260
bool system_tray;
261261
std::vector<prep_cmd_t> prep_cmds;
262+
263+
// List of allowed origins for CSRF protection (e.g., "https://example.com,https://app.example.com")
264+
// Comma-separated list of additional origins. Default includes localhost variants and web UI port.
265+
std::vector<std::string> csrf_allowed_origins;
262266
};
263267

264268
extern video_t video;

0 commit comments

Comments
 (0)