fix(csrf): make errors more apparent (#4889)

This commit is contained in:
David Lane
2026-03-22 12:39:37 -04:00
committed by GitHub
parent 26d5c1e4c1
commit 24b66fedda
11 changed files with 283 additions and 74 deletions
+10
View File
@@ -335,17 +335,23 @@ namespace confighttp {
const auto token_it = csrf_tokens.find(client_id);
if (token_it == csrf_tokens.end()) {
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF token validation failed: no token found for client"sv;
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);
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF token validation failed: token expired"sv;
bad_request(response, request, "CSRF token expired");
return false;
}
if (token_it->second.token != provided_token) {
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF token validation failed: token mismatch"sv;
bad_request(response, request, "Invalid CSRF token");
return false;
}
@@ -390,6 +396,7 @@ namespace confighttp {
// A browser-like request arrived with an Origin/Referer that doesn't match an allowed origin.
// Require a CSRF token.
const std::string_view blocked_origin = (origin_it != request->header.end()) ? origin_it->second : referer_it->second;
// Extract token from X-CSRF-Token header
const auto header_it = request->header.find("X-CSRF-Token");
if (header_it == request->header.end()) {
@@ -397,6 +404,9 @@ namespace confighttp {
auto query_params = request->parse_query_string();
const auto query_it = query_params.find("csrf_token");
if (query_it == query_params.end()) {
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF protection blocked request from origin: "sv << blocked_origin;
BOOST_LOG(error) << "Web UI: To allow this origin, add it to the 'csrf_allowed_origins' option in your Sunshine configuration"sv;
bad_request(response, request, "Missing CSRF token");
return false;
}
+5
View File
@@ -1,4 +1,5 @@
<template>
<div>
<nav class="navbar navbar-expand-lg navbar-sunshine">
<div class="container-fluid">
<a class="navbar-brand" href="./" title="Sunshine">
@@ -59,15 +60,19 @@
</div>
</div>
</nav>
<Notification></Notification>
</div>
</template>
<script>
import { Home, Lock, Layers, Star, Settings, Shield, Info } from 'lucide-vue-next'
import ThemeToggle from './ThemeToggle.vue'
import Notification from './Notification.vue'
export default {
components: {
ThemeToggle,
Notification,
Home,
Lock,
Layers,
@@ -0,0 +1,150 @@
<template>
<div class="notification-container" v-if="state.notifications.length > 0">
<div
v-for="n in state.notifications"
:key="n.id"
class="alert d-flex align-items-start gap-2 mb-2"
:class="'alert-' + n.type"
role="alert"
>
<component :is="iconFor(n.type)" :size="18" class="icon flex-shrink-0 mt-1"></component>
<div class="flex-grow-1">
<div v-if="n.titleKey || n.title">
<strong>{{ n.titleKey ? $t(n.titleKey) : n.title }}</strong>
</div>
<span>{{ n.messageKey ? $t(n.messageKey) : n.message }}</span>
</div>
<button type="button" class="btn-close" :aria-label="$t('_common.dismiss')" @click="dismiss(n.id)"></button>
</div>
</div>
</template>
<script>
import { reactive } from 'vue'
import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-vue-next'
/**
* Singleton reactive notification state shared across the app instance.
* Using reactive() at module scope means all consumers — including plain JS
* modules like fetch_utils.js — mutate the same reactive object, and any
* mounted Notification component will update automatically.
*/
export const state = reactive({
notifications: [],
_nextId: 1,
})
/**
* Push a notification using raw strings.
*
* @param {'danger'|'warning'|'success'|'info'} type - Bootstrap color variant.
* @param {string} message - The notification body text.
* @param {string} [title] - Optional bold title prefix.
*/
function push(type, message, title) {
state.notifications.push({ id: state._nextId++, type, message, title: title || null, messageKey: null, titleKey: null })
}
/**
* Push a notification using i18n translation keys.
*
* @param {'danger'|'warning'|'success'|'info'} type - Bootstrap color variant.
* @param {string} messageKey - i18n key for the notification body.
* @param {string} [titleKey] - Optional i18n key for the bold title prefix.
*/
function pushKey(type, messageKey, titleKey) {
state.notifications.push({ id: state._nextId++, type, message: null, title: null, messageKey, titleKey: titleKey || null })
}
/**
* Map a Bootstrap color variant to its corresponding lucide-vue-next icon name.
*
* @param {'danger'|'warning'|'success'|'info'} type - Bootstrap color variant.
* @returns {string} The lucide icon component name.
*/
function iconFor(type) {
return { danger: 'AlertCircle', warning: 'AlertTriangle', success: 'CheckCircle', info: 'Info' }[type] || 'Info'
}
/**
* Convenience helpers for the four common variants using raw strings.
*/
export const notify = {
/**
* @param {string} message - The notification body text.
* @param {string} [title] - Optional bold title shown above the message.
*/
error: (message, title) => push('danger', message, title),
/**
* @param {string} message - The notification body text.
* @param {string} [title] - Optional bold title shown above the message.
*/
warning: (message, title) => push('warning', message, title),
/**
* @param {string} message - The notification body text.
* @param {string} [title] - Optional bold title shown above the message.
*/
success: (message, title) => push('success', message, title),
/**
* @param {string} message - The notification body text.
* @param {string} [title] - Optional bold title shown above the message.
*/
info: (message, title) => push('info', message, title),
}
/**
* Convenience helpers for the four common variants using i18n keys.
*/
export const notifyKey = {
/**
* @param {string} messageKey - i18n key for the notification body text.
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
*/
error: (messageKey, titleKey) => pushKey('danger', messageKey, titleKey),
/**
* @param {string} messageKey - i18n key for the notification body text.
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
*/
warning: (messageKey, titleKey) => pushKey('warning', messageKey, titleKey),
/**
* @param {string} messageKey - i18n key for the notification body text.
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
*/
success: (messageKey, titleKey) => pushKey('success', messageKey, titleKey),
/**
* @param {string} messageKey - i18n key for the notification body text.
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
*/
info: (messageKey, titleKey) => pushKey('info', messageKey, titleKey),
}
export default {
components: { AlertCircle, AlertTriangle, CheckCircle, Info },
setup() {
function dismiss(id) {
const idx = state.notifications.findIndex(n => n.id === id)
if (idx !== -1) state.notifications.splice(idx, 1)
}
return { state, dismiss, iconFor }
},
}
</script>
<style scoped>
.notification-container {
position: fixed;
bottom: 1rem;
right: 1rem;
left: 1rem;
z-index: 1090;
}
@media (min-width: 576px) {
.notification-container {
left: auto;
width: 22rem;
}
}
</style>
+4 -3
View File
@@ -481,6 +481,7 @@
import { initApp } from './init'
import Navbar from './Navbar.vue'
import Checkbox from './Checkbox.vue'
import { apiFetch } from './fetch_utils'
import { Modal } from 'bootstrap/dist/js/bootstrap'
import {
ArrowRight,
@@ -608,7 +609,7 @@
"Are you sure to delete " + this.apps[id].name + "?"
);
if (resp) {
fetch("./api/apps/" + id, {
apiFetch("./api/apps/" + id, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
@@ -705,7 +706,7 @@
},
useCover(cover) {
this.coverFinderBusy = true;
fetch("./api/covers/upload", {
apiFetch("./api/covers/upload", {
method: "POST",
headers: {
'Content-Type': 'application/json'
@@ -808,7 +809,7 @@
},
save() {
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
fetch("./api/apps", {
apiFetch("./api/apps", {
method: "POST",
headers: {
'Content-Type': 'application/json'
+3 -2
View File
@@ -125,6 +125,7 @@
import { computed, createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import { apiFetch } from './fetch_utils'
import General from './configs/tabs/General.vue'
import Inputs from './configs/tabs/Inputs.vue'
import Network from './configs/tabs/Network.vue'
@@ -469,7 +470,7 @@
});
});
return fetch("./api/config", {
return apiFetch("./api/config", {
method: "POST",
headers: {
'Content-Type': 'application/json'
@@ -495,7 +496,7 @@
setTimeout(() => {
this.saved = this.restarted = false;
}, 5000);
fetch("./api/restart", {
apiFetch("./api/restart", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -0,0 +1,33 @@
import { notifyKey } from './Notification.vue'
/**
* The set of error messages that indicate a CSRF validation failure.
*/
const CSRF_ERRORS = new Set(['Missing CSRF token', 'Invalid CSRF token', 'CSRF token expired'])
/**
* Wrapper around the native fetch that automatically detects CSRF errors
* (HTTP 400 with a known CSRF error message) and displays a notification.
*
* @param {string} url - The URL to fetch.
* @param {RequestInit} [options] - Standard fetch options.
* @returns {Promise<Response>} The fetch Response.
*/
export async function apiFetch(url, options) {
const response = await fetch(url, options)
if (response.status === 400) {
let body = null
try {
body = await response.clone().json()
} catch (e) {
console.debug('apiFetch: response body is not JSON', e)
}
if (body && CSRF_ERRORS.has(body.error)) {
notifyKey.error('_common.csrf_error_desc', '_common.csrf_error')
}
}
return response
}
+2 -1
View File
@@ -64,6 +64,7 @@
import { createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import { apiFetch } from './fetch_utils'
import { Save } from 'lucide-vue-next'
const app = createApp({
@@ -87,7 +88,7 @@
methods: {
save() {
this.error = null;
fetch("./api/password", {
apiFetch("./api/password", {
method: "POST",
headers: {
'Content-Type': 'application/json'
+2 -1
View File
@@ -40,6 +40,7 @@
import { createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import { apiFetch } from './fetch_utils'
import {
Forward,
Hash,
@@ -60,7 +61,7 @@
let name = document.querySelector("#name-input").value;
document.querySelector("#status").innerHTML = "";
let b = JSON.stringify({pin: pin, name: name});
fetch("./api/pin", {
apiFetch("./api/pin", {
method: "POST",
headers: {
'Content-Type': 'application/json'
@@ -8,6 +8,8 @@
"browse": "Browse",
"cancel": "Cancel",
"close": "Close",
"csrf_error": "CSRF Protection Error",
"csrf_error_desc": "The request was blocked by CSRF protection. If you are accessing Sunshine from a non-default URL or reverse proxy, add that origin to the 'csrf_allowed_origins' option in your configuration.",
"disabled": "Disabled",
"disabled_def": "Disabled (default)",
"disabled_def_cbox": "Default: unchecked",
@@ -193,6 +193,7 @@
import { createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import { apiFetch } from './fetch_utils'
import {
AlertCircle,
AlertTriangle,
@@ -410,7 +411,7 @@
},
closeApp() {
this.closeAppPressed = true;
fetch("./api/apps/close", {
apiFetch("./api/apps/close", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -426,7 +427,7 @@
},
unpairAll() {
this.unpairAllPressed = true;
fetch("./api/clients/unpair-all", {
apiFetch("./api/clients/unpair-all", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -443,7 +444,7 @@
});
},
unpairSingle(uuid) {
fetch("./api/clients/unpair", {
apiFetch("./api/clients/unpair", {
method: "POST",
headers: {
'Content-Type': 'application/json'
@@ -493,7 +494,7 @@
setTimeout(() => {
this.restartPressed = false;
}, 5000);
fetch("./api/restart", {
apiFetch("./api/restart", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -502,7 +503,7 @@
},
ddResetPersistence() {
this.ddResetPressed = true;
fetch("/api/reset-display-device-persistence", {
apiFetch("/api/reset-display-device-persistence", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -536,7 +537,7 @@
this.vigemBusInstallPressed = true;
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
fetch("/api/vigembus/install", {
apiFetch("/api/vigembus/install", {
method: "POST",
headers: {
"Content-Type": "application/json"
+6 -2
View File
@@ -6,6 +6,7 @@
</head>
<body id="app" v-cloak>
<Notification></Notification>
<main role="main" style="max-width: 1200px; margin: 1em auto">
<div class="d-flex gap-4">
<div class="card p-2">
@@ -55,10 +56,13 @@
import { createApp } from "vue"
import ResourceCard from './ResourceCard.vue'
import { initApp } from './init'
import { apiFetch } from './fetch_utils'
import Notification from './Notification.vue'
let app = createApp({
components: {
ResourceCard
ResourceCard,
Notification,
},
data() {
return {
@@ -76,7 +80,7 @@
save() {
this.error = null;
this.loading = true;
fetch("./api/password", {
apiFetch("./api/password", {
method: "POST",
headers: {
'Content-Type': 'application/json'