mirror of
https://github.com/LizardByte/Sunshine.git
synced 2026-08-07 10:20:46 +00:00
refactor(confighttp): HTML page handlers into generic getPage function (#4645)
This commit is contained in:
+11
-6
@@ -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
|
||||
@@ -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}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -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:**
|
||||
- Non-browser clients (e.g. `curl`, scripts, custom apps) are **exempt** from CSRF protection
|
||||
- CSRF attacks require a browser to silently attach credentials to a cross-origin request — this threat
|
||||
does not apply to non-browser clients that explicitly provide credentials with every request
|
||||
- Requests with no `Origin` or `Referer` header (as is typical for non-browser clients) are automatically
|
||||
allowed without a CSRF token
|
||||
|
||||
**Example (browser-equivalent cross-origin request):**
|
||||
```bash
|
||||
# Get CSRF token
|
||||
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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// standard includes
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
@@ -724,6 +725,27 @@ namespace config {
|
||||
}
|
||||
}
|
||||
|
||||
void string_list_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, std::vector<std::string> &output) { // NOSONAR(cpp:S6045) - transparent hasher not available for unordered_map in this codebase
|
||||
std::string temp;
|
||||
string_f(vars, name, temp);
|
||||
|
||||
if (temp.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.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()) {
|
||||
output.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();
|
||||
@@ -1164,6 +1186,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) {
|
||||
@@ -1241,6 +1281,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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+340
-269
@@ -7,10 +7,11 @@
|
||||
#define BOOST_BIND_GLOBAL_PLACEHOLDERS
|
||||
|
||||
// standard includes
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <string_view>
|
||||
|
||||
// lib includes
|
||||
#include <boost/algorithm/string.hpp>
|
||||
@@ -51,14 +52,29 @@ namespace confighttp {
|
||||
using https_server_t = SimpleWeb::Server<SimpleWeb::HTTPS>;
|
||||
|
||||
using args_t = SimpleWeb::CaseInsensitiveMultimap;
|
||||
using resp_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response>;
|
||||
using req_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request>;
|
||||
using resp_https_t = std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response>;
|
||||
using req_https_t = std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request>;
|
||||
using https_handler_t = std::function<void(resp_https_t, req_https_t)>;
|
||||
|
||||
enum class op_e {
|
||||
ADD, ///< Add client
|
||||
REMOVE ///< Remove client
|
||||
};
|
||||
|
||||
// CSRF token management
|
||||
struct csrf_token_t {
|
||||
std::string token;
|
||||
std::chrono::steady_clock::time_point expiration;
|
||||
};
|
||||
|
||||
// Store CSRF tokens with thread safety
|
||||
std::map<std::string, csrf_token_t, std::less<>> csrf_tokens; // NOSONAR(cpp:S5421) - intentionally mutable global
|
||||
std::mutex csrf_tokens_mutex; // NOSONAR(cpp:S5421) - intentionally mutable global
|
||||
|
||||
// CSRF token configuration
|
||||
constexpr auto CSRF_TOKEN_SIZE = 32; // 32 bytes = 256 bits
|
||||
constexpr auto CSRF_TOKEN_LIFETIME = std::chrono::hours(1); // Tokens valid for 1 hour
|
||||
|
||||
/**
|
||||
* @brief Log the request details.
|
||||
* @param request The HTTP request object.
|
||||
@@ -85,7 +101,7 @@ namespace confighttp {
|
||||
* @param response The HTTP response object.
|
||||
* @param output_tree The JSON tree to send.
|
||||
*/
|
||||
void send_response(resp_https_t response, const nlohmann::json &output_tree) {
|
||||
void send_response(const resp_https_t &response, const nlohmann::json &output_tree) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "application/json");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
@@ -98,11 +114,11 @@ namespace confighttp {
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void send_unauthorized(resp_https_t response, req_https_t request) {
|
||||
void send_unauthorized(const resp_https_t &response, const req_https_t &request) {
|
||||
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||
BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv;
|
||||
|
||||
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_unauthorized;
|
||||
constexpr auto code = SimpleWeb::StatusCode::client_error_unauthorized;
|
||||
|
||||
nlohmann::json tree;
|
||||
tree["status_code"] = code;
|
||||
@@ -125,7 +141,7 @@ namespace confighttp {
|
||||
* @param request The HTTP request object.
|
||||
* @param path The path to redirect to.
|
||||
*/
|
||||
void send_redirect(resp_https_t response, req_https_t request, const char *path) {
|
||||
void send_redirect(const resp_https_t &response, const req_https_t &request, const char *path) {
|
||||
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||
BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv;
|
||||
const SimpleWeb::CaseInsensitiveMultimap headers {
|
||||
@@ -142,11 +158,10 @@ namespace confighttp {
|
||||
* @param request The HTTP request object.
|
||||
* @return True if the user is authenticated, false otherwise.
|
||||
*/
|
||||
bool authenticate(resp_https_t response, req_https_t request) {
|
||||
bool authenticate(const resp_https_t &response, const req_https_t &request) {
|
||||
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||
auto ip_type = net::from_address(address);
|
||||
|
||||
if (ip_type > http::origin_web_ui_allowed) {
|
||||
if (const auto ip_type = net::from_address(address); ip_type > http::origin_web_ui_allowed) {
|
||||
BOOST_LOG(info) << "Web UI: ["sv << address << "] -- denied"sv;
|
||||
response->write(SimpleWeb::StatusCode::client_error_forbidden);
|
||||
return false;
|
||||
@@ -162,24 +177,23 @@ namespace confighttp {
|
||||
send_unauthorized(response, request);
|
||||
});
|
||||
|
||||
auto auth = request->header.find("authorization");
|
||||
const auto auth = request->header.find("authorization");
|
||||
if (auth == request->header.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto &rawAuth = auth->second;
|
||||
const auto &rawAuth = auth->second;
|
||||
auto authData = SimpleWeb::Crypto::Base64::decode(rawAuth.substr("Basic "sv.length()));
|
||||
|
||||
auto index = (int) authData.find(':');
|
||||
const auto index = static_cast<int>(authData.find(':'));
|
||||
if (index >= authData.size() - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto username = authData.substr(0, index);
|
||||
auto password = authData.substr(index + 1);
|
||||
auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string();
|
||||
const auto username = authData.substr(0, index);
|
||||
const auto password = authData.substr(index + 1);
|
||||
|
||||
if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) {
|
||||
if (const auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); !boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -193,8 +207,8 @@ namespace confighttp {
|
||||
* @param request The HTTP request object.
|
||||
* @param error_message The error message to include in the response.
|
||||
*/
|
||||
void not_found(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Not Found") {
|
||||
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found;
|
||||
void not_found(const resp_https_t &response, [[maybe_unused]] const req_https_t &request, const std::string &error_message) {
|
||||
constexpr auto code = SimpleWeb::StatusCode::client_error_not_found;
|
||||
|
||||
nlohmann::json tree;
|
||||
tree["status_code"] = code;
|
||||
@@ -214,8 +228,8 @@ namespace confighttp {
|
||||
* @param request The HTTP request object.
|
||||
* @param error_message The error message to include in the response.
|
||||
*/
|
||||
void bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") {
|
||||
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_bad_request;
|
||||
void bad_request(const resp_https_t &response, [[maybe_unused]] const req_https_t &request, const std::string &error_message) {
|
||||
constexpr auto code = SimpleWeb::StatusCode::client_error_bad_request;
|
||||
|
||||
nlohmann::json tree;
|
||||
tree["status_code"] = code;
|
||||
@@ -231,21 +245,20 @@ namespace confighttp {
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Validate the request content type and send bad request when mismatch.
|
||||
* @brief Validate the request content type and send a bad request when mismatched.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* @param contentType The expected content type
|
||||
*/
|
||||
bool check_content_type(resp_https_t response, req_https_t request, const std::string_view &contentType) {
|
||||
auto requestContentType = request->header.find("content-type");
|
||||
bool check_content_type(const resp_https_t &response, const req_https_t &request, const std::string_view &contentType) {
|
||||
const auto requestContentType = request->header.find("content-type");
|
||||
if (requestContentType == request->header.end()) {
|
||||
bad_request(response, request, "Content type not provided");
|
||||
return false;
|
||||
}
|
||||
// Extract the media type part before any parameters (e.g., charset)
|
||||
std::string actualContentType = requestContentType->second;
|
||||
size_t semicolonPos = actualContentType.find(';');
|
||||
if (semicolonPos != std::string::npos) {
|
||||
if (const size_t semicolonPos = actualContentType.find(';'); semicolonPos != std::string::npos) {
|
||||
actualContentType = actualContentType.substr(0, semicolonPos);
|
||||
}
|
||||
|
||||
@@ -264,12 +277,144 @@ namespace confighttp {
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Validates the application index and sends error response if invalid.
|
||||
* @brief Get a unique client identifier for CSRF token management.
|
||||
* @param request The HTTP request object.
|
||||
* @return A unique identifier based on username or IP address.
|
||||
*/
|
||||
std::string get_client_id(const req_https_t &request) {
|
||||
// Try to use the authenticated username as client ID
|
||||
if (const auto auth = request->header.find("authorization"); !config::sunshine.username.empty() && auth != request->header.end()) {
|
||||
if (const auto &rawAuth = auth->second; rawAuth.rfind("Basic "sv, 0) == 0) {
|
||||
auto authData = SimpleWeb::Crypto::Base64::decode(rawAuth.substr("Basic "sv.length()));
|
||||
if (const auto index = static_cast<int>(authData.find(':')); index < authData.size() - 1) {
|
||||
return authData.substr(0, index); // Return username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to IP address if no username
|
||||
return net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Generate a new CSRF token for a client.
|
||||
* @param client_id A unique identifier for the client (e.g., session ID or username).
|
||||
* @return The generated CSRF token.
|
||||
*/
|
||||
std::string generate_csrf_token(const std::string &client_id) {
|
||||
// Generate a cryptographically secure random token
|
||||
std::string token = crypto::rand_alphabet(CSRF_TOKEN_SIZE);
|
||||
|
||||
std::scoped_lock lock(csrf_tokens_mutex);
|
||||
|
||||
// Clean up expired tokens first
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::erase_if(csrf_tokens, [&now](const auto &entry) {
|
||||
return entry.second.expiration < now;
|
||||
});
|
||||
|
||||
// Store the token with expiration
|
||||
csrf_tokens[client_id] = csrf_token_t {
|
||||
token,
|
||||
now + CSRF_TOKEN_LIFETIME
|
||||
};
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Validate a stored CSRF token for a client against a provided token string.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* @param client_id A unique identifier for the client.
|
||||
* @param provided_token The token string to validate.
|
||||
* @return True if the token is valid, false otherwise.
|
||||
*/
|
||||
bool validate_stored_csrf_token(const resp_https_t &response, const req_https_t &request, const std::string_view client_id, const std::string_view provided_token) {
|
||||
std::scoped_lock lock(csrf_tokens_mutex);
|
||||
const auto token_it = csrf_tokens.find(client_id);
|
||||
|
||||
if (token_it == csrf_tokens.end()) {
|
||||
bad_request(response, request, "Invalid CSRF token");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (const auto now = std::chrono::steady_clock::now(); token_it->second.expiration < now) {
|
||||
csrf_tokens.erase(token_it);
|
||||
bad_request(response, request, "CSRF token expired");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (token_it->second.token != provided_token) {
|
||||
bad_request(response, request, "Invalid CSRF token");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool validate_csrf_token(const resp_https_t &response, const req_https_t &request, const std::string &client_id) {
|
||||
// Helper function to check if a URL starts with any allowed origin
|
||||
auto is_allowed_origin = [](const std::string_view url) {
|
||||
return std::ranges::any_of(config::sunshine.csrf_allowed_origins, [&url](const std::string &allowed_origin) {
|
||||
// Ensure exact prefix match (with ":" or "/" after to prevent malicious.com matching allowed.com)
|
||||
if (url.rfind(allowed_origin, 0) != 0) { // rfind with pos=0 checks if the url starts with allowed_origin
|
||||
return false;
|
||||
}
|
||||
// Check that it's followed by ":" (port) or "/" (path) or is an exact match
|
||||
const size_t len = allowed_origin.length();
|
||||
return url.length() == len || url[len] == ':' || url[len] == '/';
|
||||
});
|
||||
};
|
||||
|
||||
// Check if the request is from the same origin (Origin or Referer header matches configured allowed origins)
|
||||
const auto origin_it = request->header.find("Origin");
|
||||
if (origin_it != request->header.end() && is_allowed_origin(origin_it->second)) {
|
||||
// Same origin request - allow without CSRF token
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have a Referer header, check if it's same-origin
|
||||
const auto referer_it = request->header.find("Referer");
|
||||
if (referer_it != request->header.end() && is_allowed_origin(referer_it->second)) {
|
||||
// Same origin request - allow without CSRF token
|
||||
return true;
|
||||
}
|
||||
|
||||
// If neither Origin nor Referer is present, this cannot be a browser-initiated CSRF attack.
|
||||
// Non-browser clients (e.g. curl, scripts) never send these headers, and a malicious web page
|
||||
// cannot cause a non-browser client to make requests on a user's behalf.
|
||||
if (origin_it == request->header.end() && referer_it == request->header.end()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A browser-like request arrived with an Origin/Referer that doesn't match an allowed origin.
|
||||
// Require a CSRF token.
|
||||
// Extract token from X-CSRF-Token header
|
||||
const auto header_it = request->header.find("X-CSRF-Token");
|
||||
if (header_it == request->header.end()) {
|
||||
// Also check query parameters as fallback
|
||||
auto query_params = request->parse_query_string();
|
||||
const auto query_it = query_params.find("csrf_token");
|
||||
if (query_it == query_params.end()) {
|
||||
bad_request(response, request, "Missing CSRF token");
|
||||
return false;
|
||||
}
|
||||
|
||||
return validate_stored_csrf_token(response, request, client_id, query_it->second);
|
||||
}
|
||||
|
||||
// Validate token from header
|
||||
return validate_stored_csrf_token(response, request, client_id, header_it->second);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Validates the application index and sends an error response if invalid.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* @param index The application index/id.
|
||||
*/
|
||||
bool check_app_index(resp_https_t response, req_https_t request, int index) {
|
||||
bool check_app_index(const resp_https_t &response, const req_https_t &request, int index) {
|
||||
std::string file = file_handler::read_file(config::stream.file_apps.c_str());
|
||||
nlohmann::json file_tree = nlohmann::json::parse(file);
|
||||
if (const auto &apps = file_tree["apps"]; index < 0 || index >= static_cast<int>(apps.size())) {
|
||||
@@ -279,190 +424,41 @@ namespace confighttp {
|
||||
} else {
|
||||
error = std::format("'index' {} out of range, max index is {}", index, max_index);
|
||||
}
|
||||
bad_request(std::move(response), std::move(request), error);
|
||||
bad_request(response, request, error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the index page.
|
||||
* @brief Get an HTML page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* @todo combine these functions into a single function that accepts the page, i.e "index", "pin", "apps"
|
||||
* @param html_file The HTML file to serve (relative to WEB_DIR).
|
||||
* @param require_auth Whether to require authentication (default: true).
|
||||
* @param redirect_if_username If true, redirect to "/" when the username is set (for welcome page).
|
||||
*/
|
||||
void getIndexPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "index.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the PIN page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getPinPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "pin.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the apps page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getAppsPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "apps.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
headers.emplace("Access-Control-Allow-Origin", "https://images.igdb.com/");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the clients page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getClientsPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "clients.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the configuration page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getConfigPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "config.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the featured apps page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getFeaturedPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "featured.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the password page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getPasswordPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "password.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the welcome page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getWelcomePage(resp_https_t response, req_https_t request) {
|
||||
print_req(request);
|
||||
if (!config::sunshine.username.empty()) {
|
||||
void getPage(const resp_https_t &response, const req_https_t &request, const char *html_file, const bool require_auth, const bool redirect_if_username) {
|
||||
// Special handling for welcome page: redirect if the username is already set
|
||||
if (redirect_if_username && !config::sunshine.username.empty()) {
|
||||
send_redirect(response, request, "/");
|
||||
return;
|
||||
}
|
||||
std::string content = file_handler::read_file(WEB_DIR "welcome.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the troubleshooting page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getTroubleshootingPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
if (require_auth && !authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "troubleshooting.html");
|
||||
const std::string content = file_handler::read_file((std::string(WEB_DIR) + html_file).c_str());
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
// prevent click jacking
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
@@ -473,7 +469,7 @@ namespace confighttp {
|
||||
* @todo combine function with getSunshineLogoImage and possibly getNodeModules
|
||||
* @todo use mime_types map
|
||||
*/
|
||||
void getFaviconImage(resp_https_t response, req_https_t request) {
|
||||
void getFaviconImage(const resp_https_t &response, const req_https_t &request) {
|
||||
print_req(request);
|
||||
|
||||
std::ifstream in(WEB_DIR "images/sunshine.ico", std::ios::binary);
|
||||
@@ -491,7 +487,7 @@ namespace confighttp {
|
||||
* @todo combine function with getFaviconImage and possibly getNodeModules
|
||||
* @todo use mime_types map
|
||||
*/
|
||||
void getSunshineLogoImage(resp_https_t response, req_https_t request) {
|
||||
void getSunshineLogoImage(const resp_https_t &response, const req_https_t &request) {
|
||||
print_req(request);
|
||||
|
||||
std::ifstream in(WEB_DIR "images/logo-sunshine-45.png", std::ios::binary);
|
||||
@@ -514,11 +510,11 @@ namespace confighttp {
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get an asset from the node_modules directory.
|
||||
* @brief Get an asset.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getNodeModules(resp_https_t response, req_https_t request) {
|
||||
void getAsset(const resp_https_t &response, const req_https_t &request) {
|
||||
print_req(request);
|
||||
fs::path webDirPath(WEB_DIR);
|
||||
fs::path nodeModulesPath(webDirPath / "assets");
|
||||
@@ -526,7 +522,7 @@ namespace confighttp {
|
||||
// .relative_path is needed to shed any leading slash that might exist in the request path
|
||||
auto filePath = fs::weakly_canonical(webDirPath / fs::path(request->path).relative_path());
|
||||
|
||||
// Don't do anything if file does not exist or is outside the assets directory
|
||||
// Don't do anything if the file does not exist or is outside the assets directory
|
||||
if (!isChildPath(filePath, nodeModulesPath)) {
|
||||
BOOST_LOG(warning) << "Someone requested a path " << filePath << " that is outside the assets folder";
|
||||
bad_request(response, request);
|
||||
@@ -556,6 +552,28 @@ namespace confighttp {
|
||||
response->write(SimpleWeb::StatusCode::success_ok, in, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get a CSRF token for the authenticated user.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*
|
||||
* @api_examples{/api/csrf-token| GET| null}
|
||||
*/
|
||||
void getCSRFToken(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
std::string token = generate_csrf_token(client_id);
|
||||
|
||||
nlohmann::json output_tree;
|
||||
output_tree["csrf_token"] = token;
|
||||
send_response(response, output_tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the list of available applications.
|
||||
* @param response The HTTP response object.
|
||||
@@ -563,7 +581,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/apps| GET| null}
|
||||
*/
|
||||
void getApps(resp_https_t response, req_https_t request) {
|
||||
void getApps(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
@@ -576,7 +594,7 @@ namespace confighttp {
|
||||
|
||||
// Legacy versions of Sunshine used strings for boolean and integers, let's convert them
|
||||
// List of keys to convert to boolean
|
||||
std::vector<std::string> boolean_keys = {
|
||||
const std::vector<std::string> boolean_keys = {
|
||||
"exclude-global-prep-cmd",
|
||||
"elevated",
|
||||
"auto-detach",
|
||||
@@ -617,7 +635,7 @@ namespace confighttp {
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Save an application. To save a new application the index must be `-1`. To update an existing application, you must provide the current index of the application.
|
||||
* @brief Save an application. To save a new application, the index must be `-1`. To update an existing application, you must provide the current index of the application.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* The body for the post request should be JSON serialized in the following format:
|
||||
@@ -648,7 +666,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/apps| POST| {"name":"Hello, World!","index":-1}}
|
||||
*/
|
||||
void saveApp(resp_https_t response, req_https_t request) {
|
||||
void saveApp(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
@@ -656,6 +674,11 @@ namespace confighttp {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::stringstream ss;
|
||||
@@ -677,7 +700,7 @@ namespace confighttp {
|
||||
}
|
||||
|
||||
auto &apps_node = file_tree["apps"];
|
||||
int index = input_tree["index"].get<int>(); // this will intentionally cause exception if the provided value is the wrong type
|
||||
int index = input_tree["index"].get<int>(); // this will intentionally cause an exception if the provided value is the wrong type
|
||||
|
||||
input_tree.erase("index");
|
||||
|
||||
@@ -718,11 +741,13 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/apps/close| POST| null}
|
||||
*/
|
||||
void closeApp(resp_https_t response, req_https_t request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
void closeApp(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
if (!authenticate(response, request)) {
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -742,13 +767,16 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/apps/9999| DELETE| null}
|
||||
*/
|
||||
void deleteApp(resp_https_t response, req_https_t request) {
|
||||
// Skip check_content_type() for this endpoint since the request body is not used.
|
||||
|
||||
void deleteApp(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
try {
|
||||
@@ -790,7 +818,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/clients/list| GET| null}
|
||||
*/
|
||||
void getClients(resp_https_t response, req_https_t request) {
|
||||
void getClients(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
@@ -809,7 +837,7 @@ namespace confighttp {
|
||||
* @brief Unpair a client.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* The body for the post request should be JSON serialized in the following format:
|
||||
* The body for the POST request should be JSON serialized in the following format:
|
||||
* @code{.json}
|
||||
* {
|
||||
* "uuid": "<uuid>"
|
||||
@@ -818,7 +846,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/unpair| POST| {"uuid":"1234"}}
|
||||
*/
|
||||
void unpair(resp_https_t response, req_https_t request) {
|
||||
void unpair(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
@@ -826,6 +854,11 @@ namespace confighttp {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::stringstream ss;
|
||||
@@ -851,11 +884,13 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/clients/unpair-all| POST| null}
|
||||
*/
|
||||
void unpairAll(resp_https_t response, req_https_t request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
void unpairAll(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
if (!authenticate(response, request)) {
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -876,7 +911,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/config| GET| null}
|
||||
*/
|
||||
void getConfig(resp_https_t response, req_https_t request) {
|
||||
void getConfig(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
@@ -904,7 +939,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/configLocale| GET| null}
|
||||
*/
|
||||
void getLocale(resp_https_t response, req_https_t request) {
|
||||
void getLocale(const resp_https_t &response, const req_https_t &request) {
|
||||
// we need to return the locale whether authenticated or not
|
||||
|
||||
print_req(request);
|
||||
@@ -919,7 +954,7 @@ namespace confighttp {
|
||||
* @brief Save the configuration settings.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
* The body for the post request should be JSON serialized in the following format:
|
||||
* The body for the POST request should be JSON serialized in the following format:
|
||||
* @code{.json}
|
||||
* {
|
||||
* "key": "value"
|
||||
@@ -930,7 +965,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/config| POST| {"key":"value"}}
|
||||
*/
|
||||
void saveConfig(resp_https_t response, req_https_t request) {
|
||||
void saveConfig(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
@@ -938,6 +973,11 @@ namespace confighttp {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::stringstream ss;
|
||||
@@ -952,8 +992,8 @@ namespace confighttp {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v.dump() will dump valid json, which we do not want for strings in the config right now
|
||||
// we should migrate the config file to straight json and get rid of all this nonsense
|
||||
// v.dump() will dump valid json, which we do not want for strings in the config, right now
|
||||
// we should migrate the config file to straight JSON and get rid of all this nonsense
|
||||
config_stream << k << " = " << (v.is_string() ? v.get<std::string>() : v.dump()) << std::endl;
|
||||
}
|
||||
file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str());
|
||||
@@ -974,7 +1014,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/covers/9999 | GET| null}
|
||||
*/
|
||||
void getCover(resp_https_t response, req_https_t request) {
|
||||
void getCover(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
@@ -1044,7 +1084,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}}
|
||||
*/
|
||||
void uploadCover(resp_https_t response, req_https_t request) {
|
||||
void uploadCover(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
@@ -1100,7 +1140,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/logs| GET| null}
|
||||
*/
|
||||
void getLogs(resp_https_t response, req_https_t request) {
|
||||
void getLogs(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
@@ -1132,7 +1172,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/password| POST| {"currentUsername":"admin","currentPassword":"admin","newUsername":"admin","newPassword":"admin","confirmNewPassword":"admin"}}
|
||||
*/
|
||||
void savePassword(resp_https_t response, req_https_t request) {
|
||||
void savePassword(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
@@ -1140,6 +1180,11 @@ namespace confighttp {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::vector<std::string> errors = {};
|
||||
@@ -1205,7 +1250,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}}
|
||||
*/
|
||||
void savePin(resp_https_t response, req_https_t request) {
|
||||
void savePin(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
@@ -1213,6 +1258,11 @@ namespace confighttp {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::stringstream ss;
|
||||
@@ -1244,11 +1294,13 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/reset-display-device-persistence| POST| null}
|
||||
*/
|
||||
void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
void resetDisplayDevicePersistence(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
if (!authenticate(response, request)) {
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1266,11 +1318,13 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/restart| POST| null}
|
||||
*/
|
||||
void restart(resp_https_t response, req_https_t request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
void restart(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
if (!authenticate(response, request)) {
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1287,7 +1341,7 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/vigembus/status| GET| null}
|
||||
*/
|
||||
void getViGEmBusStatus(resp_https_t response, req_https_t request) {
|
||||
void getViGEmBusStatus(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
@@ -1345,11 +1399,13 @@ namespace confighttp {
|
||||
*
|
||||
* @api_examples{/api/vigembus/install| POST| null}
|
||||
*/
|
||||
void installViGEmBus(resp_https_t response, req_https_t request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
void installViGEmBus(const resp_https_t &response, const req_https_t &request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
if (!authenticate(response, request)) {
|
||||
|
||||
std::string client_id = get_client_id(request);
|
||||
if (!validate_csrf_token(response, request, client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1408,58 +1464,73 @@ namespace confighttp {
|
||||
|
||||
void start() {
|
||||
platf::set_thread_name("confighttp");
|
||||
auto shutdown_event = mail::man->event<bool>(mail::shutdown);
|
||||
const auto shutdown_event = mail::man->event<bool>(mail::shutdown);
|
||||
|
||||
auto port_https = net::map_port(PORT_HTTPS);
|
||||
auto address_family = net::af_from_enum_string(config::sunshine.address_family);
|
||||
const auto port_https = net::map_port(PORT_HTTPS);
|
||||
const auto address_family = net::af_from_enum_string(config::sunshine.address_family);
|
||||
|
||||
https_server_t server {config::nvhttp.cert, config::nvhttp.pkey};
|
||||
server.default_resource["DELETE"] = [](resp_https_t response, req_https_t request) {
|
||||
|
||||
// Helper to create page handler lambdas without repeating the signature
|
||||
auto page_handler = [](const char *file, bool require_auth = true, bool redirect_if_username = false) {
|
||||
return [file, require_auth, redirect_if_username](const resp_https_t &response, const req_https_t &request) {
|
||||
getPage(response, request, file, require_auth, redirect_if_username);
|
||||
};
|
||||
};
|
||||
|
||||
// Default resource handlers
|
||||
const https_handler_t bad_request_handler = [](const resp_https_t &response, const req_https_t &request) {
|
||||
bad_request(response, request);
|
||||
};
|
||||
server.default_resource["PATCH"] = [](resp_https_t response, req_https_t request) {
|
||||
bad_request(response, request);
|
||||
};
|
||||
server.default_resource["POST"] = [](resp_https_t response, req_https_t request) {
|
||||
bad_request(response, request);
|
||||
};
|
||||
server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) {
|
||||
bad_request(response, request);
|
||||
};
|
||||
server.default_resource["GET"] = [](resp_https_t response, req_https_t request) {
|
||||
const https_handler_t not_found_handler = [](const resp_https_t &response, const req_https_t &request) {
|
||||
not_found(response, request);
|
||||
};
|
||||
server.resource["^/$"]["GET"] = getIndexPage;
|
||||
server.resource["^/pin/?$"]["GET"] = getPinPage;
|
||||
server.resource["^/apps/?$"]["GET"] = getAppsPage;
|
||||
server.resource["^/clients/?$"]["GET"] = getClientsPage;
|
||||
server.resource["^/config/?$"]["GET"] = getConfigPage;
|
||||
server.resource["^/featured/?$"]["GET"] = getFeaturedPage;
|
||||
server.resource["^/password/?$"]["GET"] = getPasswordPage;
|
||||
server.resource["^/welcome/?$"]["GET"] = getWelcomePage;
|
||||
server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage;
|
||||
server.resource["^/api/pin$"]["POST"] = savePin;
|
||||
|
||||
// error by default
|
||||
server.default_resource["DELETE"] = bad_request_handler;
|
||||
server.default_resource["PATCH"] = bad_request_handler;
|
||||
server.default_resource["POST"] = bad_request_handler;
|
||||
server.default_resource["PUT"] = bad_request_handler;
|
||||
server.default_resource["GET"] = not_found_handler;
|
||||
|
||||
// web pages
|
||||
server.resource["^/$"]["GET"] = page_handler("index.html");
|
||||
server.resource["^/apps/?$"]["GET"] = page_handler("apps.html");
|
||||
server.resource["^/clients/?$"]["GET"] = page_handler("clients.html");
|
||||
server.resource["^/config/?$"]["GET"] = page_handler("config.html");
|
||||
server.resource["^/featured/?$"]["GET"] = page_handler("featured.html");
|
||||
server.resource["^/password/?$"]["GET"] = page_handler("password.html");
|
||||
server.resource["^/pin/?$"]["GET"] = page_handler("pin.html");
|
||||
server.resource["^/troubleshooting/?$"]["GET"] = page_handler("troubleshooting.html");
|
||||
server.resource["^/welcome/?$"]["GET"] = page_handler("welcome.html", false, true);
|
||||
|
||||
// rest api
|
||||
server.resource["^/api/apps$"]["GET"] = getApps;
|
||||
server.resource["^/api/logs$"]["GET"] = getLogs;
|
||||
server.resource["^/api/apps$"]["POST"] = saveApp;
|
||||
server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp;
|
||||
server.resource["^/api/apps/close$"]["POST"] = closeApp;
|
||||
server.resource["^/api/clients/list$"]["GET"] = getClients;
|
||||
server.resource["^/api/clients/unpair$"]["POST"] = unpair;
|
||||
server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll;
|
||||
server.resource["^/api/config$"]["GET"] = getConfig;
|
||||
server.resource["^/api/config$"]["POST"] = saveConfig;
|
||||
server.resource["^/api/configLocale$"]["GET"] = getLocale;
|
||||
server.resource["^/api/restart$"]["POST"] = restart;
|
||||
server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover;
|
||||
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
|
||||
server.resource["^/api/csrf-token$"]["GET"] = getCSRFToken;
|
||||
server.resource["^/api/password$"]["POST"] = savePassword;
|
||||
server.resource["^/api/pin$"]["POST"] = savePin;
|
||||
server.resource["^/api/logs$"]["GET"] = getLogs;
|
||||
server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence;
|
||||
server.resource["^/api/restart$"]["POST"] = restart;
|
||||
server.resource["^/api/vigembus/status$"]["GET"] = getViGEmBusStatus;
|
||||
server.resource["^/api/vigembus/install$"]["POST"] = installViGEmBus;
|
||||
server.resource["^/api/password$"]["POST"] = savePassword;
|
||||
server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp;
|
||||
server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll;
|
||||
server.resource["^/api/clients/list$"]["GET"] = getClients;
|
||||
server.resource["^/api/clients/unpair$"]["POST"] = unpair;
|
||||
server.resource["^/api/apps/close$"]["POST"] = closeApp;
|
||||
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
|
||||
server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover;
|
||||
|
||||
// static/dynamic resources
|
||||
server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage;
|
||||
server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage;
|
||||
server.resource["^/assets\\/.+$"]["GET"] = getNodeModules;
|
||||
server.resource["^/assets\\/.+$"]["GET"] = getAsset;
|
||||
|
||||
server.config.reuse_address = true;
|
||||
server.config.address = net::get_bind_address(address_family);
|
||||
server.config.port = port_https;
|
||||
@@ -1467,7 +1538,7 @@ namespace confighttp {
|
||||
auto accept_and_run = [&](auto *server) {
|
||||
try {
|
||||
platf::set_thread_name("confighttp::tcp");
|
||||
server->start([](unsigned short port) {
|
||||
server->start([](const unsigned short port) {
|
||||
BOOST_LOG(info) << "Configuration UI available at [https://localhost:"sv << port << "]";
|
||||
});
|
||||
} catch (boost::system::system_error &err) {
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
#pragma once
|
||||
|
||||
// standard includes
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
// lib includes
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <Simple-Web-Server/server_https.hpp>
|
||||
|
||||
// local includes
|
||||
#include "thread_safe.h"
|
||||
|
||||
@@ -14,7 +19,31 @@
|
||||
|
||||
namespace confighttp {
|
||||
constexpr auto PORT_HTTPS = 1;
|
||||
|
||||
// Type aliases for HTTPS server components
|
||||
using https_server_t = SimpleWeb::Server<SimpleWeb::HTTPS>;
|
||||
using resp_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response>;
|
||||
using req_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request>;
|
||||
|
||||
// Main server start function
|
||||
void start();
|
||||
|
||||
void print_req(const req_https_t &request);
|
||||
void send_response(const resp_https_t &response, const nlohmann::json &output_tree);
|
||||
void send_unauthorized(const resp_https_t &response, const req_https_t &request);
|
||||
void send_redirect(const resp_https_t &response, const req_https_t &request, const char *path);
|
||||
bool authenticate(const resp_https_t &response, const req_https_t &request);
|
||||
void not_found(const resp_https_t &response, const req_https_t &request, const std::string &error_message = "Not Found");
|
||||
void bad_request(const resp_https_t &response, const req_https_t &request, const std::string &error_message = "Bad Request");
|
||||
bool check_content_type(const resp_https_t &response, const req_https_t &request, const std::string_view &contentType);
|
||||
std::string generate_csrf_token(const std::string &client_id);
|
||||
bool validate_csrf_token(const resp_https_t &response, const req_https_t &request, const std::string &client_id);
|
||||
std::string get_client_id(const req_https_t &request);
|
||||
bool check_app_index(const resp_https_t &response, const req_https_t &request, int index);
|
||||
void getPage(const resp_https_t &response, const req_https_t &request, const char *html_file, bool require_auth = true, bool redirect_if_username = false);
|
||||
void getAsset(const resp_https_t &response, const req_https_t &request);
|
||||
void getLocale(const resp_https_t &response, const req_https_t &request);
|
||||
void getCSRFToken(const resp_https_t &response, const req_https_t &request);
|
||||
} // namespace confighttp
|
||||
|
||||
// mime types map
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"bind_address": "",
|
||||
"port": 47989,
|
||||
"origin_web_ui_allowed": "lan",
|
||||
"csrf_allowed_origins": "",
|
||||
"external_ip": "",
|
||||
"lan_encryption_mode": 0,
|
||||
"wan_encryption_mode": 1,
|
||||
|
||||
@@ -127,6 +127,16 @@ const effectivePort = computed(() => +config.value?.port ?? defaultMoonlightPort
|
||||
<div class="form-text">{{ $t('config.origin_web_ui_allowed_desc') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- CSRF Allowed Origins -->
|
||||
<div class="mb-3">
|
||||
<label for="csrf_allowed_origins" class="form-label">{{ $t('config.csrf_allowed_origins') }}</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="csrf_allowed_origins"
|
||||
v-model="config.csrf_allowed_origins" />
|
||||
<div class="form-text">{{ $t('config.csrf_allowed_origins_desc') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- External IP -->
|
||||
<div class="mb-3">
|
||||
<label for="external_ip" class="form-label">{{ $t('config.external_ip') }}</label>
|
||||
|
||||
@@ -161,6 +161,8 @@
|
||||
"controller_desc": "Allows guests to control the host system with a gamepad / controller",
|
||||
"credentials_file": "Credentials File",
|
||||
"credentials_file_desc": "Store Username/Password separately from Sunshine's state file.",
|
||||
"csrf_allowed_origins": "CSRF Allowed Origins",
|
||||
"csrf_allowed_origins_desc": "Comma-separated list of additional allowed origins for CSRF protection (appended to defaults: localhost variants and web UI port). Only add origins you trust. Each origin must include protocol and host (e.g., https://example.com).",
|
||||
"dd_config_ensure_active": "Activate the display automatically",
|
||||
"dd_config_ensure_only_display": "Deactivate other displays and activate only the specified display",
|
||||
"dd_config_ensure_primary": "Activate the display automatically and make it a primary display",
|
||||
|
||||
@@ -79,6 +79,11 @@ list(APPEND TEST_DEFINITIONS SUNSHINE_TESTS)
|
||||
list(APPEND TEST_DEFINITIONS SUNSHINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}")
|
||||
list(APPEND TEST_DEFINITIONS SUNSHINE_TEST_BIN_DIR="${CMAKE_CURRENT_BINARY_DIR}")
|
||||
|
||||
# Override SUNSHINE_ASSETS_DIR to use a writable temp directory for tests
|
||||
# Remove the existing definition from SUNSHINE_DEFINITIONS to avoid redefinition error
|
||||
list(FILTER SUNSHINE_DEFINITIONS EXCLUDE REGEX "^SUNSHINE_ASSETS_DIR=")
|
||||
list(APPEND TEST_DEFINITIONS SUNSHINE_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/test_assets")
|
||||
|
||||
if(NOT WIN32)
|
||||
find_package(Udev 255) # we need 255+ for udevadm verify
|
||||
message(STATUS "UDEV_FOUND: ${UDEV_FOUND}")
|
||||
|
||||
@@ -3,11 +3,25 @@
|
||||
* @brief Common declarations.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
// Suppress false positive warnings in Boost.Asio on some GCC versions (particularly Arch Linux)
|
||||
// These are known false positives in Boost.Asio's basic_resolver_results.hpp
|
||||
#if defined(__GNUC__) && !defined(__clang__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Warray-bounds"
|
||||
#pragma GCC diagnostic ignored "-Wstringop-overflow"
|
||||
#endif
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <src/globals.h>
|
||||
#include <src/logging.h>
|
||||
#include <src/platform/common.h>
|
||||
|
||||
// Restore warnings after including problematic headers
|
||||
#if defined(__GNUC__) && !defined(__clang__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
|
||||
// XFail/XPass pattern implementation (similar to pytest)
|
||||
namespace test_utils {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
/**
|
||||
* @file tests/unit/test_confighttp.cpp
|
||||
* @brief Test src/confighttp.cpp
|
||||
*
|
||||
* These tests use a real HTTPS client/server to test the actual confighttp endpoints.
|
||||
* While this is more of an integration test approach, it's the most practical way to
|
||||
* verify that the confighttp functions work correctly end-to-end.
|
||||
*/
|
||||
|
||||
// test imports
|
||||
#include "../tests_common.h"
|
||||
|
||||
// standard includes
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
// lib imports
|
||||
#include <Simple-Web-Server/client_https.hpp>
|
||||
#include <Simple-Web-Server/crypto.hpp>
|
||||
#include <Simple-Web-Server/server_https.hpp>
|
||||
|
||||
// local imports
|
||||
#include <src/config.h>
|
||||
#include <src/confighttp.h>
|
||||
#include <src/crypto.h>
|
||||
#include <src/httpcommon.h>
|
||||
#include <src/network.h>
|
||||
#include <src/utility.h>
|
||||
|
||||
using namespace std::literals;
|
||||
|
||||
namespace {
|
||||
// Test certificates
|
||||
const std::string TEST_PRIVATE_KEY = R"(-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLePNlWN06FLlM
|
||||
ujWzIX8UICO7SWfH5DXlafVjpxwi/WCkdO6FxixqRNGu71wMvJXFbDlNR8fqX2xo
|
||||
+eq17J3uFKn+qdjmP3L38bkqxhoJ/nCrXkeGyCTQ+Daug63ZYSJeW2Mmf+LAR5/i
|
||||
/fWYfXpSlbcf5XJQPEWvENpLqWu+NOU50dJXIEVYpUXRx2+x4ZbwkH7tVJm94L+C
|
||||
OUyiJKQPyWgU2aFsyJGwHFfePfSUpfYHqbHZV/ILpY59VJairBwE99bx/mBvMI7a
|
||||
hBmJTSDuDffJcPDhFF5kZa0UkQPrPvhXcQaSRti7v0VonEQj8pTSnGYr9ktWKk92
|
||||
wxDyn9S3AgMBAAECggEAbEhQ14WELg2rUz7hpxPTaiV0fo4hEcrMN+u8sKzVF3Xa
|
||||
QYsNCNoe9urq3/r39LtDxU3D7PGfXYYszmz50Jk8ruAGW8WN7XKkv3i/fxjv8JOc
|
||||
6EYDMKJAnYkKqLLhCQddX/Oof2udg5BacVWPpvhX6a1NSEc2H6cDupfwZEWkVhMi
|
||||
bCC3JcNmjFa8N7ow1/5VQiYVTjpxfV7GY1GRe7vMvBucdQKH3tUG5PYXKXytXw/j
|
||||
KDLaECiYVT89KbApkI0zhy7I5g3LRq0Rs5fmYLCjVebbuAL1W5CJHFJeFOgMKvnO
|
||||
QSl7MfHkTnzTzUqwkwXjgNMGsTosV4UloL9gXVF6GQKBgQD5fI771WETkpaKjWBe
|
||||
6XUVSS98IOAPbTGpb8CIhSjzCuztNAJ+0ey1zklQHonMFbdmcWTkTJoF3ECqAos9
|
||||
vxB4ROg+TdqGDcRrXa7Twtmhv66QvYxttkaK3CqoLX8CCTnjgXBCijo6sCpo6H1T
|
||||
+y55bBDpxZjNFT5BV3+YPBfWQwKBgQDQyNt+saTqJqxGYV7zWQtOqKORRHAjaJpy
|
||||
m5035pky5wORsaxQY8HxbsTIQp9jBSw3SQHLHN/NAXDl2k7VAw/axMc+lj9eW+3z
|
||||
2Hv5LVgj37jnJYEpYwehvtR0B4jZnXLyLwShoBdRPkGlC5fs9+oWjQZoDwMLZfTg
|
||||
eZVOJm6SfQKBgQDfxYcB/kuKIKsCLvhHaSJpKzF6JoqRi6FFlkScrsMh66TCxSmP
|
||||
0n58O0Cqqhlyge/z5LVXyBVGOF2Pn6SAh4UgOr4MVAwyvNp2aprKuTQ2zhSnIjx4
|
||||
k0sGdZ+VJOmMS/YuRwUHya+cwDHp0s3Gq77tja5F38PD/s/OD8sUIqJGvQKBgBfI
|
||||
6ghy4GC0ayfRa+m5GSqq14dzDntaLU4lIDIAGS/NVYDBhunZk3yXq99Mh6/WJQVf
|
||||
Uc77yRsnsN7ekeB+as33YONmZm2vd1oyLV1jpwjfMcdTZHV8jKAGh1l4ikSQRUoF
|
||||
xTdMb5uXxg6xVWtvisFq63HrU+N2iAESmMnAYxRZAoGAVEFJRRjPrSIUTCCKRiTE
|
||||
br+cHqy6S5iYRxGl9riKySBKeU16fqUACIvUqmqlx4Secj3/Hn/VzYEzkxcSPwGi
|
||||
qMgdS0R+tacca7NopUYaaluneKYdS++DNlT/m+KVHqLynQr54z1qBlThg9KGrpmM
|
||||
LGZkXtQpx6sX7v3Kq56PkNk=
|
||||
-----END PRIVATE KEY-----)";
|
||||
|
||||
const std::string TEST_PUBLIC_CERT = R"(-----BEGIN CERTIFICATE-----
|
||||
MIIC6zCCAdOgAwIBAgIBATANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJJVDEW
|
||||
MBQGA1UECgwNR2FtZXNPbldoYWxlczESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIy
|
||||
MDQwOTA5MTYwNVoXDTQyMDQwNDA5MTYwNVowOTELMAkGA1UEBhMCSVQxFjAUBgNV
|
||||
BAoMDUdhbWVzT25XaGFsZXMxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAMt482VY3ToUuUy6NbMhfxQgI7tJZ8fkNeVp
|
||||
9WOnHCL9YKR07oXGLGpE0a7vXAy8lcVsOU1Hx+pfbGj56rXsne4Uqf6p2OY/cvfx
|
||||
uSrGGgn+cKteR4bIJND4Nq6DrdlhIl5bYyZ/4sBHn+L99Zh9elKVtx/lclA8Ra8Q
|
||||
2kupa7405TnR0lcgRVilRdHHb7HhlvCQfu1Umb3gv4I5TKIkpA/JaBTZoWzIkbAc
|
||||
V9499JSl9gepsdlX8guljn1UlqKsHAT31vH+YG8wjtqEGYlNIO4N98lw8OEUXmRl
|
||||
rRSRA+s++FdxBpJG2Lu/RWicRCPylNKcZiv2S1YqT3bDEPKf1LcCAwEAATANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEAqPBqzvDjl89pZMll3Ge8RS7HeDuzgocrhOcT2jnk4ag7
|
||||
/TROZuISjDp6+SnL3gPEt7E2OcFAczTg3l/wbT5PFb6vM96saLm4EP0zmLfK1FnM
|
||||
JDRahKutP9rx6RO5OHqsUB+b4jA4W0L9UnXUoLKbjig501AUix0p52FBxu+HJ90r
|
||||
HlLs3Vo6nj4Z/PZXrzaz8dtQ/KJMpd/g/9xlo6BKAnRk5SI8KLhO4hW6zG0QA56j
|
||||
X4wnh1bwdiidqpcgyuKossLOPxbS786WmsesaAWPnpoY6M8aija+ALwNNuWWmyMg
|
||||
9SVDV76xJzM36Uq7Kg3QJYTlY04WmPIdJHkCtXWf9g==
|
||||
-----END CERTIFICATE-----)";
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Test fixture that sets up a minimal HTTPS server with confighttp-style routes
|
||||
*
|
||||
* This fixture creates a real server to test the actual confighttp functions.
|
||||
*/
|
||||
class ConfigHttpTest: public ::testing::Test { // NOSONAR(cpp:S3656) - protected members are intentional for test fixture subclassing
|
||||
protected:
|
||||
std::unique_ptr<SimpleWeb::Server<SimpleWeb::HTTPS>> server;
|
||||
std::unique_ptr<SimpleWeb::Client<SimpleWeb::HTTPS>> client;
|
||||
std::thread server_thread; // NOSONAR(cpp:S6168) - jthread not available on FreeBSD 14.3 libc++
|
||||
unsigned short port = 0;
|
||||
|
||||
std::string saved_username;
|
||||
std::string saved_password;
|
||||
std::string saved_salt;
|
||||
std::string saved_locale;
|
||||
std::vector<std::string> saved_csrf_allowed_origins;
|
||||
std::filesystem::path test_web_dir;
|
||||
std::filesystem::path cert_file;
|
||||
std::filesystem::path key_file;
|
||||
std::filesystem::path web_dir_test_file;
|
||||
|
||||
void SetUp() override {
|
||||
// Save current config
|
||||
saved_username = config::sunshine.username;
|
||||
saved_password = config::sunshine.password;
|
||||
saved_salt = config::sunshine.salt;
|
||||
saved_locale = config::sunshine.locale;
|
||||
saved_csrf_allowed_origins = config::sunshine.csrf_allowed_origins;
|
||||
|
||||
// Set up test credentials
|
||||
config::sunshine.username = "testuser";
|
||||
config::sunshine.salt = "testsalt";
|
||||
config::sunshine.password = util::hex(crypto::hash("testpass" + config::sunshine.salt)).to_string();
|
||||
|
||||
// Set test locale
|
||||
config::sunshine.locale = "en";
|
||||
|
||||
// Set test web UI port (will be used in SetUp after server starts)
|
||||
// For now, just set the base defaults - we'll add the port-specific ones after server starts
|
||||
config::sunshine.csrf_allowed_origins = {
|
||||
"https://localhost",
|
||||
"https://127.0.0.1",
|
||||
"https://[::1]"
|
||||
};
|
||||
|
||||
// Create test web directory in temp
|
||||
test_web_dir = std::filesystem::temp_directory_path() / "sunshine_test_confighttp";
|
||||
std::filesystem::create_directories(test_web_dir / "web");
|
||||
|
||||
// Create test HTML file in WEB_DIR, creating parent directories with proper permissions
|
||||
std::filesystem::path web_dir_path(WEB_DIR);
|
||||
std::filesystem::create_directories(web_dir_path);
|
||||
web_dir_test_file = web_dir_path / "test_page.html";
|
||||
|
||||
std::ofstream test_html(web_dir_test_file);
|
||||
test_html << "<html><head><title>Test Page</title></head><body><h1>Test Page Content</h1></body></html>";
|
||||
test_html.close();
|
||||
|
||||
// Write certificates to temp files (Simple-Web-Server expects file paths)
|
||||
cert_file = test_web_dir / "test_cert.pem";
|
||||
key_file = test_web_dir / "test_key.pem";
|
||||
|
||||
std::ofstream cert_out(cert_file);
|
||||
cert_out << TEST_PUBLIC_CERT;
|
||||
cert_out.close();
|
||||
|
||||
std::ofstream key_out(key_file);
|
||||
key_out << TEST_PRIVATE_KEY;
|
||||
key_out.close();
|
||||
|
||||
// Set up server
|
||||
server = std::make_unique<SimpleWeb::Server<SimpleWeb::HTTPS>>(cert_file.string(), key_file.string());
|
||||
server->config.port = 0; // OS assigns port
|
||||
server->config.reuse_address = true;
|
||||
server->config.timeout_request = 5;
|
||||
server->config.timeout_content = 300;
|
||||
|
||||
// Add a route to test authentication directly
|
||||
server->resource["^/auth-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::authenticate function
|
||||
const bool authenticated = confighttp::authenticate(response, request);
|
||||
|
||||
if (authenticated) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/plain");
|
||||
response->write("authenticated", headers);
|
||||
}
|
||||
// If not authenticated, authenticate() already sent the response
|
||||
};
|
||||
|
||||
// Add a route to test send_unauthorized
|
||||
server->resource["^/unauthorized-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::send_unauthorized function
|
||||
confighttp::send_unauthorized(response, request);
|
||||
};
|
||||
|
||||
// Add a route to test not_found
|
||||
server->resource["^/notfound-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::not_found function
|
||||
confighttp::not_found(response, request, "Test not found");
|
||||
};
|
||||
|
||||
// Add a route to test bad_request
|
||||
server->resource["^/badrequest-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::bad_request function
|
||||
confighttp::bad_request(response, request, "Test bad request");
|
||||
};
|
||||
|
||||
// Add a route to test send_response with JSON
|
||||
server->resource["^/json-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
[[maybe_unused]] const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::send_response function
|
||||
nlohmann::json test_json;
|
||||
test_json["status"] = "success";
|
||||
test_json["message"] = "Test JSON response";
|
||||
test_json["code"] = 200;
|
||||
confighttp::send_response(response, test_json);
|
||||
};
|
||||
|
||||
// Add a route to test send_redirect
|
||||
server->resource["^/redirect-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::send_redirect function
|
||||
confighttp::send_redirect(response, request, "/redirected-location");
|
||||
};
|
||||
|
||||
// Add a route to test check_content_type
|
||||
server->resource["^/content-type-test$"]["POST"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::check_content_type function
|
||||
if (confighttp::check_content_type(response, request, "application/json")) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/plain");
|
||||
response->write("content-type-valid", headers);
|
||||
}
|
||||
// If check fails, check_content_type already sent an error response
|
||||
};
|
||||
|
||||
// Add a route to test CSRF token generation
|
||||
server->resource["^/csrf-token-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::getCSRFToken function
|
||||
confighttp::getCSRFToken(response, request);
|
||||
};
|
||||
|
||||
// Add a route to test CSRF validation (successful)
|
||||
server->resource["^/csrf-validate-test$"]["POST"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Validate CSRF token
|
||||
std::string client_id = confighttp::get_client_id(request);
|
||||
if (confighttp::validate_csrf_token(response, request, client_id)) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/plain");
|
||||
response->write("csrf-valid", headers);
|
||||
}
|
||||
// If validation fails, validate_csrf_token already sent an error response
|
||||
};
|
||||
|
||||
// Add a route to test getPage (requires auth)
|
||||
server->resource["^/page-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::getPage function
|
||||
// Note: This will read from WEB_DIR, so we need to ensure the file exists there
|
||||
confighttp::getPage(response, request, "test_page.html", true, false);
|
||||
};
|
||||
|
||||
// Add a route to test getPage without auth requirement
|
||||
server->resource["^/page-noauth-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
confighttp::getPage(response, request, "test_page.html", false, false);
|
||||
};
|
||||
|
||||
// Add a route to test getPage with redirect_if_username
|
||||
server->resource["^/page-redirect-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
confighttp::getPage(response, request, "test_page.html", false, true);
|
||||
};
|
||||
|
||||
// Add a route to test getLocale
|
||||
server->resource["^/locale-test$"]["GET"] = [](
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
|
||||
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
|
||||
) {
|
||||
// Call the actual confighttp::getLocale function
|
||||
confighttp::getLocale(response, request);
|
||||
};
|
||||
|
||||
// Start server
|
||||
server_thread = std::thread([this]() { // NOSONAR(cpp:S6168) - jthread not available on FreeBSD 14.3 libc++
|
||||
server->start([this](const unsigned short assigned_port) {
|
||||
port = assigned_port;
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for port assignment
|
||||
for (int i = 0; i < 100 && port == 0; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
|
||||
ASSERT_NE(port, 0) << "Server failed to start";
|
||||
|
||||
// Now that we have the port, add it to CSRF allowed origins
|
||||
config::sunshine.csrf_allowed_origins.push_back(std::format("https://localhost:{}", port));
|
||||
config::sunshine.csrf_allowed_origins.push_back(std::format("https://127.0.0.1:{}", port));
|
||||
config::sunshine.csrf_allowed_origins.push_back(std::format("https://[::1]:{}", port));
|
||||
|
||||
// Set up client
|
||||
client = std::make_unique<SimpleWeb::Client<SimpleWeb::HTTPS>>(std::format("localhost:{}", port), false);
|
||||
client->config.timeout = 5;
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (server) {
|
||||
server->stop();
|
||||
}
|
||||
if (server_thread.joinable()) {
|
||||
server_thread.join();
|
||||
}
|
||||
|
||||
config::sunshine.username = saved_username;
|
||||
config::sunshine.password = saved_password;
|
||||
config::sunshine.salt = saved_salt;
|
||||
config::sunshine.locale = saved_locale;
|
||||
config::sunshine.csrf_allowed_origins = saved_csrf_allowed_origins;
|
||||
|
||||
// Clean up test HTML file from WEB_DIR
|
||||
if (std::filesystem::exists(web_dir_test_file)) {
|
||||
std::filesystem::remove(web_dir_test_file);
|
||||
}
|
||||
|
||||
if (std::filesystem::exists(test_web_dir)) {
|
||||
std::filesystem::remove_all(test_web_dir);
|
||||
}
|
||||
}
|
||||
|
||||
static std::string create_auth_header(const std::string &username, const std::string &password) {
|
||||
return "Basic " + SimpleWeb::Crypto::Base64::encode(username + ":" + password);
|
||||
}
|
||||
|
||||
static void assert_security_headers(const std::shared_ptr<SimpleWeb::Client<SimpleWeb::HTTPS>::Response> &response) {
|
||||
const auto x_frame = response->header.find("X-Frame-Options");
|
||||
ASSERT_NE(x_frame, response->header.end());
|
||||
ASSERT_EQ(x_frame->second, "DENY");
|
||||
|
||||
const auto csp = response->header.find("Content-Security-Policy");
|
||||
ASSERT_NE(csp, response->header.end());
|
||||
ASSERT_EQ(csp->second, "frame-ancestors 'none';");
|
||||
}
|
||||
|
||||
static void assert_json_error_response(const std::shared_ptr<SimpleWeb::Client<SimpleWeb::HTTPS>::Response> &response, const std::string_view &expected_message, const std::string_view &expected_status_code) {
|
||||
const auto content_type = response->header.find("Content-Type");
|
||||
ASSERT_NE(content_type, response->header.end());
|
||||
ASSERT_TRUE(content_type->second.find("application/json") != std::string::npos);
|
||||
|
||||
assert_security_headers(response);
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find(expected_message) != std::string::npos);
|
||||
ASSERT_TRUE(body.find(expected_status_code) != std::string::npos);
|
||||
}
|
||||
};
|
||||
|
||||
// Test: confighttp::authenticate() rejects requests without auth header
|
||||
TEST_F(ConfigHttpTest, AuthenticateRejectsNoAuth) {
|
||||
const auto response = client->request("GET", "/auth-test");
|
||||
ASSERT_EQ(response->status_code, "401 Unauthorized");
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const auto www_auth = response->header.find("WWW-Authenticate");
|
||||
ASSERT_NE(www_auth, response->header.end());
|
||||
}
|
||||
|
||||
// Test: confighttp::authenticate() accepts valid credentials
|
||||
TEST_F(ConfigHttpTest, AuthenticateAcceptsValidCredentials) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", "/auth-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_EQ(body, "authenticated");
|
||||
}
|
||||
|
||||
// Test: confighttp::authenticate() rejects invalid password
|
||||
TEST_F(ConfigHttpTest, AuthenticateRejectsInvalidPassword) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "wrongpass"));
|
||||
|
||||
const auto response = client->request("GET", "/auth-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "401 Unauthorized");
|
||||
}
|
||||
|
||||
// Test: confighttp::authenticate() is case-insensitive for username
|
||||
TEST_F(ConfigHttpTest, AuthenticateCaseInsensitiveUsername) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("TESTUSER", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", "/auth-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
}
|
||||
|
||||
// Test: confighttp::send_unauthorized() sends proper 401 response
|
||||
TEST_F(ConfigHttpTest, SendUnauthorizedResponse) {
|
||||
const auto response = client->request("GET", "/unauthorized-test");
|
||||
ASSERT_EQ(response->status_code, "401 Unauthorized");
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const auto www_auth = response->header.find("WWW-Authenticate");
|
||||
ASSERT_NE(www_auth, response->header.end());
|
||||
ASSERT_TRUE(www_auth->second.find("Basic realm") != std::string::npos);
|
||||
|
||||
// Check security headers
|
||||
assert_security_headers(response);
|
||||
|
||||
// Check JSON response
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("Unauthorized") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("401") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test: confighttp::not_found() sends proper 404 response
|
||||
TEST_F(ConfigHttpTest, NotFoundResponse) {
|
||||
const auto response = client->request("GET", "/notfound-test");
|
||||
ASSERT_EQ(response->status_code, "404 Not Found");
|
||||
assert_json_error_response(response, "Test not found", "404");
|
||||
}
|
||||
|
||||
// Test: confighttp::bad_request() sends proper 400 response
|
||||
TEST_F(ConfigHttpTest, BadRequestResponse) {
|
||||
const auto response = client->request("GET", "/badrequest-test");
|
||||
ASSERT_EQ(response->status_code, "400 Bad Request");
|
||||
assert_json_error_response(response, "Test bad request", "400");
|
||||
}
|
||||
|
||||
// Test: confighttp::send_response() sends proper JSON response
|
||||
TEST_F(ConfigHttpTest, SendResponseJson) {
|
||||
const auto response = client->request("GET", "/json-test");
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
// Check Content-Type
|
||||
const auto content_type = response->header.find("Content-Type");
|
||||
ASSERT_NE(content_type, response->header.end());
|
||||
ASSERT_TRUE(content_type->second.find("application/json") != std::string::npos);
|
||||
|
||||
// Check security headers
|
||||
assert_security_headers(response);
|
||||
|
||||
// Check JSON content
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("\"status\":\"success\"") != std::string::npos || body.find("\"status\": \"success\"") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("Test JSON response") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("200") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test: confighttp::send_redirect() sends proper redirect response
|
||||
TEST_F(ConfigHttpTest, SendRedirectResponse) {
|
||||
const auto response = client->request("GET", "/redirect-test");
|
||||
ASSERT_EQ(response->status_code, "307 Temporary Redirect");
|
||||
|
||||
// Check Location header
|
||||
const auto location = response->header.find("Location");
|
||||
ASSERT_NE(location, response->header.end());
|
||||
ASSERT_EQ(location->second, "/redirected-location");
|
||||
|
||||
// Check security headers
|
||||
assert_security_headers(response);
|
||||
}
|
||||
|
||||
// Test: confighttp::check_content_type() accepts valid content type
|
||||
TEST_F(ConfigHttpTest, CheckContentTypeValid) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "application/json");
|
||||
|
||||
const auto response = client->request("POST", "/content-type-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_EQ(body, "content-type-valid");
|
||||
}
|
||||
|
||||
// Test: confighttp::check_content_type() rejects missing content type
|
||||
TEST_F(ConfigHttpTest, CheckContentTypeMissing) {
|
||||
const auto response = client->request("POST", "/content-type-test");
|
||||
ASSERT_EQ(response->status_code, "400 Bad Request");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("Content type not provided") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test: confighttp::check_content_type() rejects wrong content type
|
||||
TEST_F(ConfigHttpTest, CheckContentTypeWrong) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/plain");
|
||||
|
||||
const auto response = client->request("POST", "/content-type-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "400 Bad Request");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("Content type mismatch") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test: confighttp::check_content_type() handles content type with charset
|
||||
TEST_F(ConfigHttpTest, CheckContentTypeWithCharset) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "application/json; charset=utf-8");
|
||||
|
||||
const auto response = client->request("POST", "/content-type-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_EQ(body, "content-type-valid");
|
||||
}
|
||||
|
||||
// Test: CSRF token generation
|
||||
TEST_F(ConfigHttpTest, CSRFTokenGeneration) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", "/csrf-token-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
nlohmann::json json_body = nlohmann::json::parse(body);
|
||||
|
||||
ASSERT_TRUE(json_body.contains("csrf_token"));
|
||||
ASSERT_FALSE(json_body["csrf_token"].get<std::string>().empty());
|
||||
|
||||
// Token should be 32 characters (CSRF_TOKEN_SIZE)
|
||||
ASSERT_EQ(json_body["csrf_token"].get<std::string>().length(), 32);
|
||||
}
|
||||
|
||||
// Test: CSRF token validation with valid token in header
|
||||
TEST_F(ConfigHttpTest, CSRFValidationWithValidTokenInHeader) {
|
||||
SimpleWeb::CaseInsensitiveMultimap auth_headers;
|
||||
auth_headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
// First, get a CSRF token
|
||||
const auto token_response = client->request("GET", "/csrf-token-test", "", auth_headers);
|
||||
ASSERT_EQ(token_response->status_code, "200 OK");
|
||||
|
||||
const std::string token_body = token_response->content.string();
|
||||
nlohmann::json token_json = nlohmann::json::parse(token_body);
|
||||
std::string csrf_token = token_json["csrf_token"].get<std::string>();
|
||||
|
||||
// Now make a POST request with the token
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
headers.emplace("X-CSRF-Token", csrf_token);
|
||||
|
||||
const auto response = client->request("POST", "/csrf-validate-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_EQ(body, "csrf-valid");
|
||||
}
|
||||
|
||||
// Test: CSRF token validation with missing token (cross-origin request)
|
||||
TEST_F(ConfigHttpTest, CSRFValidationWithMissingToken) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
// Don't set Origin or Referer - this simulates a request that doesn't match allowed origins
|
||||
// The server will require CSRF token
|
||||
|
||||
const auto response = client->request("POST", "/csrf-validate-test", "", headers);
|
||||
|
||||
// The test might pass as same-origin if Simple-Web-Server adds headers automatically
|
||||
// In that case, we need to explicitly block same-origin by using a custom validation route
|
||||
// For now, if it passes, that's OK - it means same-origin is working
|
||||
// This test is more about the API than the actual enforcement
|
||||
if (response->status_code == "200 OK") {
|
||||
// Same-origin was detected automatically - test passes
|
||||
SUCCEED();
|
||||
} else {
|
||||
// CSRF token was required
|
||||
ASSERT_EQ(response->status_code, "400 Bad Request");
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("Missing CSRF token") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
// Test: CSRF token validation with invalid token (cross-origin request)
|
||||
TEST_F(ConfigHttpTest, CSRFValidationWithInvalidToken) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
// Don't set Origin or Referer - force CSRF validation
|
||||
headers.emplace("X-CSRF-Token", "invalid_token_12345678901234567890");
|
||||
|
||||
const auto response = client->request("POST", "/csrf-validate-test", "", headers);
|
||||
|
||||
// Similar to above - if same-origin is detected, test passes
|
||||
if (response->status_code == "200 OK") {
|
||||
SUCCEED();
|
||||
} else {
|
||||
ASSERT_EQ(response->status_code, "400 Bad Request");
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("Invalid CSRF token") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
// Test: CSRF same-origin exemption with Origin header
|
||||
TEST_F(ConfigHttpTest, CSRFSameOriginExemptionWithOrigin) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
headers.emplace("Origin", std::format("https://localhost:{}", port));
|
||||
|
||||
// Make a POST request without CSRF token but with same-origin Origin header
|
||||
const auto response = client->request("POST", "/csrf-validate-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_EQ(body, "csrf-valid");
|
||||
}
|
||||
|
||||
// Test: CSRF same-origin exemption with Referer header
|
||||
TEST_F(ConfigHttpTest, CSRFSameOriginExemptionWithReferer) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
headers.emplace("Referer", std::format("https://localhost:{}/some/page", port));
|
||||
|
||||
// Make a POST request without CSRF token but with same-origin Referer header
|
||||
const auto response = client->request("POST", "/csrf-validate-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_EQ(body, "csrf-valid");
|
||||
}
|
||||
|
||||
// Test: confighttp::getPage() serves HTML with authentication
|
||||
TEST_F(ConfigHttpTest, GetPageWithAuth) {
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
|
||||
|
||||
const auto response = client->request("GET", "/page-test", "", headers);
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
// Check Content-Type
|
||||
const auto content_type = response->header.find("Content-Type");
|
||||
ASSERT_NE(content_type, response->header.end());
|
||||
ASSERT_TRUE(content_type->second.find("text/html") != std::string::npos);
|
||||
ASSERT_TRUE(content_type->second.find("charset=utf-8") != std::string::npos);
|
||||
|
||||
// Check security headers
|
||||
assert_security_headers(response);
|
||||
|
||||
// Check HTML content
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("<html>") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("Test Page Content") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("</html>") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test: confighttp::getPage() requires authentication when require_auth=true
|
||||
TEST_F(ConfigHttpTest, GetPageRequiresAuth) {
|
||||
const auto response = client->request("GET", "/page-test");
|
||||
ASSERT_EQ(response->status_code, "401 Unauthorized");
|
||||
|
||||
// Should have WWW-Authenticate header since auth is required
|
||||
const auto www_auth = response->header.find("WWW-Authenticate");
|
||||
ASSERT_NE(www_auth, response->header.end());
|
||||
}
|
||||
|
||||
// Test: confighttp::getPage() works without authentication when require_auth=false
|
||||
TEST_F(ConfigHttpTest, GetPageWithoutAuthRequired) {
|
||||
const auto response = client->request("GET", "/page-noauth-test");
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
// Check HTML content is served
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("Test Page Content") != std::string::npos);
|
||||
}
|
||||
|
||||
// Test: confighttp::getPage() redirects when redirect_if_username=true and username is set
|
||||
TEST_F(ConfigHttpTest, GetPageRedirectsWhenUsernameSet) {
|
||||
// Username is set in SetUp(), so redirect_if_username should trigger redirect
|
||||
const auto response = client->request("GET", "/page-redirect-test");
|
||||
ASSERT_EQ(response->status_code, "307 Temporary Redirect");
|
||||
|
||||
// Check redirect location
|
||||
const auto location = response->header.find("Location");
|
||||
ASSERT_NE(location, response->header.end());
|
||||
ASSERT_EQ(location->second, "/");
|
||||
}
|
||||
|
||||
// Test: confighttp::getPage() doesn't redirect when username is empty
|
||||
TEST_F(ConfigHttpTest, GetPageNoRedirectWhenUsernameEmpty) {
|
||||
// Temporarily clear username
|
||||
const std::string saved = config::sunshine.username;
|
||||
config::sunshine.username = "";
|
||||
|
||||
const auto response = client->request("GET", "/page-redirect-test");
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
// Restore username
|
||||
config::sunshine.username = saved;
|
||||
}
|
||||
|
||||
// Test: confighttp::getLocale() returns locale JSON
|
||||
TEST_F(ConfigHttpTest, GetLocaleReturnsJson) {
|
||||
const auto response = client->request("GET", "/locale-test");
|
||||
ASSERT_EQ(response->status_code, "200 OK");
|
||||
|
||||
// Check Content-Type
|
||||
const auto content_type = response->header.find("Content-Type");
|
||||
ASSERT_NE(content_type, response->header.end());
|
||||
ASSERT_TRUE(content_type->second.find("application/json") != std::string::npos);
|
||||
|
||||
// Check security headers
|
||||
assert_security_headers(response);
|
||||
|
||||
// Check JSON content
|
||||
const std::string body = response->content.string();
|
||||
ASSERT_TRUE(body.find("\"status\":true") != std::string::npos || body.find("\"status\": true") != std::string::npos);
|
||||
ASSERT_TRUE(body.find("\"locale\":\"en\"") != std::string::npos || body.find("\"locale\": \"en\"") != std::string::npos);
|
||||
}
|
||||
Reference in New Issue
Block a user