diff --git a/crates/proxy/admin-ui/index.html b/crates/proxy/admin-ui/index.html
index 3b0c90c..2930717 100644
--- a/crates/proxy/admin-ui/index.html
+++ b/crates/proxy/admin-ui/index.html
@@ -477,6 +477,27 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
}
}
+ // -- CSRF token --
+ // Fetched once on load and stored here. The admin SPA reads the cookie value
+ // returned by GET /admin/csrf-token and echoes it in X-CSRF-Token on mutating requests.
+ var _csrfToken = '';
+
+ function fetchCsrfToken() {
+ return fetch('/admin/csrf-token')
+ .then(function(r) {
+ if (r.ok) return r.json();
+ })
+ .then(function(data) {
+ if (data && data.csrf_token) _csrfToken = data.csrf_token;
+ })
+ .catch(function(e) { console.warn('Failed to fetch CSRF token:', e); });
+ }
+
+ // Returns authHeaders extended with X-CSRF-Token for POST/PUT/DELETE requests.
+ function mutatingHeaders() {
+ return Object.assign({}, authHeaders, {'X-CSRF-Token': _csrfToken});
+ }
+
// -- API fetch --
function apiFetch(path) {
return fetch(API + path, {headers: authHeaders}).then(function(r) { return r.json(); });
@@ -615,7 +636,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
}
});
- fetch(API + '/config', {method:'PUT', headers: authHeaders, body: JSON.stringify(body)})
+ fetch(API + '/config', {method:'PUT', headers: mutatingHeaders(), body: JSON.stringify(body)})
.then(function(r) { return r.json(); })
.then(function(result) {
alert('Updated ' + (result.updated || 0) + ' settings');
@@ -833,7 +854,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
submitBtn.textContent = 'Create';
}
- fetch(API + '/keys', {method: 'POST', headers: authHeaders, body: JSON.stringify(body)})
+ fetch(API + '/keys', {method: 'POST', headers: mutatingHeaders(), body: JSON.stringify(body)})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
@@ -862,7 +883,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
function revokeKey(id) {
if (!confirm('Revoke this key? This cannot be undone.')) return;
- fetch(API + '/keys/' + id, {method: 'DELETE', headers: authHeaders})
+ fetch(API + '/keys/' + id, {method: 'DELETE', headers: mutatingHeaders()})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
@@ -1000,7 +1021,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
submitBtn.disabled = true;
submitBtn.textContent = 'Adding...';
- fetch(API + '/models', {method: 'POST', headers: authHeaders, body: JSON.stringify(body)})
+ fetch(API + '/models', {method: 'POST', headers: mutatingHeaders(), body: JSON.stringify(body)})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
@@ -1026,7 +1047,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
function removeModel(name) {
if (!confirm('Remove model "' + name + '"? This cannot be undone.')) return;
- fetch(API + '/models/' + encodeURIComponent(name), {method: 'DELETE', headers: authHeaders})
+ fetch(API + '/models/' + encodeURIComponent(name), {method: 'DELETE', headers: mutatingHeaders()})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
@@ -1047,8 +1068,8 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
document.getElementById('btn-submit-model').addEventListener('click', addModel);
- // Initial load
- loadDashboard();
+ // Initial load: fetch CSRF token first so all subsequent mutating requests are protected.
+ fetchCsrfToken().then(function() { loadDashboard(); });
})();