Merge commit from fork

* (security) Mandate content-type on POST calls

* (security) Add JSON content-type in POST requests with a body

* Added Content Type on missing endpoints

* (review) docs and newlines

* (docs) add JSON content type header

* style(clang-format): fix lint errors

---------

Co-authored-by: axfla <axfla@hotmail.fr>
Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
This commit is contained in:
TheElixZammuto 2025-06-27 22:57:59 +02:00 committed by GitHub
commit 738ac93a0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 127 additions and 8 deletions

View file

@ -9,7 +9,7 @@ function generateExamples(endpoint, method, body = null) {
}
return {
cURL: `curl -u user:pass -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${curlBodyString}`,
cURL: `curl -u user:pass -H "Content-Type: application/json" -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${curlBodyString}`,
Python: `import json
import requests
from requests.auth import HTTPBasicAuth
@ -30,6 +30,7 @@ requests.${method.trim().toLowerCase()}(
.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'))}

View file

@ -213,6 +213,39 @@ namespace confighttp {
response->write(code, tree.dump(), headers);
}
/**
* @brief Validate the request content type and send bad request when mismatch.
* @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");
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) {
actualContentType = actualContentType.substr(0, semicolonPos);
}
// Trim whitespace and convert to lowercase for case-insensitive comparison
boost::algorithm::trim(actualContentType);
boost::algorithm::to_lower(actualContentType);
std::string expectedContentType(contentType);
boost::algorithm::to_lower(expectedContentType);
if (actualContentType != expectedContentType) {
bad_request(response, request, "Content type mismatch");
return false;
}
return true;
}
/**
* @brief Get the index page.
* @param response The HTTP response object.
@ -535,6 +568,9 @@ namespace confighttp {
* @api_examples{/api/apps| POST| {"name":"Hello, World!","index":-1}}
*/
void saveApp(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -602,6 +638,9 @@ 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")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -623,6 +662,9 @@ namespace confighttp {
* @api_examples{/api/apps/9999| DELETE| null}
*/
void deleteApp(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -703,6 +745,9 @@ namespace confighttp {
* @api_examples{/api/unpair| POST| {"uuid":"1234"}}
*/
void unpair(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -733,6 +778,9 @@ 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")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -809,6 +857,9 @@ namespace confighttp {
* @api_examples{/api/config| POST| {"key":"value"}}
*/
void saveConfig(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -855,6 +906,9 @@ 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) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -938,6 +992,9 @@ 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) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!config::sunshine.username.empty() && !authenticate(response, request)) {
return;
}
@ -1008,6 +1065,9 @@ namespace confighttp {
* @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}}
*/
void savePin(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -1044,6 +1104,9 @@ 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")) {
return;
}
if (!authenticate(response, request)) {
return;
}
@ -1063,6 +1126,9 @@ 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")) {
return;
}
if (!authenticate(response, request)) {
return;
}

View file

@ -440,7 +440,12 @@
"Are you sure to delete " + this.apps[id].name + "?"
);
if (resp) {
fetch("./api/apps/" + id, { method: "DELETE" }).then((r) => {
fetch("./api/apps/" + id, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
}).then((r) => {
if (r.status === 200) document.location.reload();
});
}
@ -540,6 +545,9 @@
this.coverFinderBusy = true;
fetch("./api/covers/upload", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
key: cover.key,
url: cover.saveUrl,
@ -555,6 +563,9 @@
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
fetch("./api/apps", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.editForm),
}).then((r) => {
if (r.status === 200) document.location.reload();

View file

@ -371,6 +371,9 @@
return fetch("./api/config", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config),
}).then((r) => {
if (r.status === 200) {
@ -393,7 +396,10 @@
this.saved = this.restarted = false;
}, 5000);
fetch("./api/restart", {
method: "POST"
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
}
});

View file

@ -92,6 +92,9 @@
this.error = null;
fetch("./api/password", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.passwordData),
}).then((r) => {
if (r.status === 200) {

View file

@ -39,7 +39,13 @@
let name = document.querySelector("#name-input").value;
document.querySelector("#status").innerHTML = "";
let b = JSON.stringify({pin: pin, name: name});
fetch("./api/pin", {method: "POST", body: b})
fetch("./api/pin", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: b
})
.then((response) => response.json())
.then((response) => {
if (response.status === true) {

View file

@ -207,7 +207,11 @@
},
closeApp() {
this.closeAppPressed = true;
fetch("./api/apps/close", { method: "POST" })
fetch("./api/apps/close", {
method: "POST",
headers: {
"Content-Type": "application/json"
} })
.then((r) => r.json())
.then((r) => {
this.closeAppPressed = false;
@ -219,7 +223,12 @@
},
unpairAll() {
this.unpairAllPressed = true;
fetch("./api/clients/unpair-all", { method: "POST" })
fetch("./api/clients/unpair-all", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
.then((r) => r.json())
.then((r) => {
this.unpairAllPressed = false;
@ -231,7 +240,13 @@
});
},
unpairSingle(uuid) {
fetch("./api/clients/unpair", { method: "POST", body: JSON.stringify({ uuid }) }).then(() => {
fetch("./api/clients/unpair", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ uuid })
}).then(() => {
this.showApplyMessage = true;
this.refreshClients();
});
@ -263,11 +278,19 @@
}, 5000);
fetch("./api/restart", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
},
ddResetPersistence() {
this.ddResetPressed = true;
fetch("/api/reset-display-device-persistence", { method: "POST" })
fetch("/api/reset-display-device-persistence", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
.then((r) => r.json())
.then((r) => {
this.ddResetPressed = false;

View file

@ -78,6 +78,9 @@
this.loading = true;
fetch("./api/password", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.passwordData),
}).then((r) => {
this.loading = false;