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
+64 -59
View File
@@ -1,73 +1,78 @@
<template>
<nav class="navbar navbar-expand-lg navbar-sunshine">
<div class="container-fluid">
<a class="navbar-brand" href="./" title="Sunshine">
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="./">
<Home :size="18" class="icon"></Home>
{{ $t('navbar.home') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./pin">
<Lock :size="18" class="icon"></Lock>
{{ $t('navbar.pin') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./apps">
<Layers :size="18" class="icon"></Layers>
{{ $t('navbar.applications') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./featured">
<Star :size="18" class="icon"></Star>
{{ $t('navbar.featured') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./config">
<Settings :size="18" class="icon"></Settings>
{{ $t('navbar.configuration') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./password">
<Shield :size="18" class="icon"></Shield>
{{ $t('navbar.password') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./troubleshooting">
<Info :size="18" class="icon"></Info>
{{ $t('navbar.troubleshoot') }}
</a>
</li>
<li class="nav-item">
<ThemeToggle/>
</li>
</ul>
<div>
<nav class="navbar navbar-expand-lg navbar-sunshine">
<div class="container-fluid">
<a class="navbar-brand" href="./" title="Sunshine">
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="./">
<Home :size="18" class="icon"></Home>
{{ $t('navbar.home') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./pin">
<Lock :size="18" class="icon"></Lock>
{{ $t('navbar.pin') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./apps">
<Layers :size="18" class="icon"></Layers>
{{ $t('navbar.applications') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./featured">
<Star :size="18" class="icon"></Star>
{{ $t('navbar.featured') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./config">
<Settings :size="18" class="icon"></Settings>
{{ $t('navbar.configuration') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./password">
<Shield :size="18" class="icon"></Shield>
{{ $t('navbar.password') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./troubleshooting">
<Info :size="18" class="icon"></Info>
{{ $t('navbar.troubleshoot') }}
</a>
</li>
<li class="nav-item">
<ThemeToggle/>
</li>
</ul>
</div>
</div>
</div>
</nav>
</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'