fix(admin): S3 Tables CSRF token + non-empty 409 status (#9221)

* fix(admin): attach CSRF token to S3 Tables write requests

Several POST/PUT/DELETE calls in s3tables.js were sent without an
X-CSRF-Token header while the corresponding handlers in
weed/admin/dash/s3tables_management.go enforce CSRF via
requireSessionCSRFToken, so authenticated users hit "invalid CSRF token"
on actions like creating a table bucket (#9220), updating policies, and
managing tags.

Add an s3tWriteHeaders helper that pulls the token from the existing
csrf-token meta tag and use it on every write to /api/s3tables/buckets,
/bucket-policy, /tables, /table-policy, and /tags. The Iceberg-page
write paths already attached the token and are unchanged.

Fixes #9220

* fix(admin): map BucketNotEmpty/NamespaceNotEmpty to 409 for S3 Tables

DELETE on a non-empty table bucket or namespace returned HTTP 500
because s3TablesErrorStatus didn't list ErrCodeBucketNotEmpty or
ErrCodeNamespaceNotEmpty in its conflict case, even though the
backend handler emits them with 409 Conflict (matching AWS S3 Tables).
Add both codes to the existing conflict mapping.

* refactor(admin): route Iceberg S3 Tables writes through s3tWriteHeaders

Iceberg namespace/table create and Iceberg table delete were still
hand-rolling CSRF headers. Replace those blocks with the existing
s3tWriteHeaders() helper so every S3 Tables write uses the same code
path. Drop the now-unused csrfTokenInput.value population in
initIcebergNamespaces and initIcebergTables (the templ hidden inputs
have no server-rendered value, and nothing reads the input now that
the JS reads the token from the meta tag via getCSRFToken()).
This commit is contained in:
Chris Lu
2026-04-24 22:48:41 -07:00
committed by GitHub
parent a14cbc176b
commit 5eead9409a
2 changed files with 23 additions and 37 deletions
+1 -1
View File
@@ -1061,7 +1061,7 @@ func s3TablesErrorStatus(err error) int {
return http.StatusNotFound
case s3tables.ErrCodeAccessDenied:
return http.StatusForbidden
case s3tables.ErrCodeBucketAlreadyExists, s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeConflict:
case s3tables.ErrCodeBucketAlreadyExists, s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeBucketNotEmpty, s3tables.ErrCodeNamespaceNotEmpty, s3tables.ErrCodeConflict:
return http.StatusConflict
}
}
+22 -36
View File
@@ -23,6 +23,15 @@ function getCSRFToken() {
return tokenMeta.getAttribute('content') || '';
}
function s3tWriteHeaders(extra) {
const headers = Object.assign({}, extra || {});
const csrfToken = getCSRFToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
return headers;
}
/**
* Initialize S3 Tables Buckets Page
*/
@@ -104,7 +113,7 @@ function initS3TablesBuckets() {
try {
const response = await fetch(s3tBasePath('/api/s3tables/buckets'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
});
const data = await response.json();
@@ -133,7 +142,7 @@ function initS3TablesBuckets() {
try {
const response = await fetch(s3tBasePath('/api/s3tables/bucket-policy'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ bucket_arn: bucketArn, policy: policy })
});
const data = await response.json();
@@ -239,7 +248,7 @@ function initS3TablesTables() {
try {
const response = await fetch(s3tBasePath('/api/s3tables/tables'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
});
const data = await response.json();
@@ -267,7 +276,7 @@ function initS3TablesTables() {
try {
const response = await fetch(s3tBasePath('/api/s3tables/table-policy'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ bucket_arn: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value, policy: policy })
});
const data = await response.json();
@@ -306,10 +315,6 @@ function initIcebergNamespaces() {
if (!container) return;
const bucketArn = container.dataset.bucketArn || '';
const catalogName = container.dataset.catalogName || '';
const csrfTokenInput = document.getElementById('icebergNamespaceCsrfToken');
if (csrfTokenInput) {
csrfTokenInput.value = getCSRFToken();
}
const namespaceInput = document.getElementById('icebergNamespaceName');
if (namespaceInput) {
@@ -330,14 +335,9 @@ function initIcebergNamespaces() {
return;
}
try {
const csrfToken = csrfTokenInput ? csrfTokenInput.value : getCSRFToken();
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch(s3tBasePath('/api/s3tables/namespaces'), {
method: 'POST',
headers: headers,
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ bucket_arn: bucketArn, name: name })
});
const data = await response.json();
@@ -433,10 +433,6 @@ function initIcebergTables() {
if (!container) return;
const bucketArn = container.dataset.bucketArn || '';
const namespace = container.dataset.namespace || '';
const csrfTokenInput = document.getElementById('icebergTableCsrfToken');
if (csrfTokenInput) {
csrfTokenInput.value = getCSRFToken();
}
initIcebergDeleteModal();
@@ -476,14 +472,9 @@ function initIcebergTables() {
payload.metadata = metadata;
}
try {
const csrfToken = csrfTokenInput ? csrfTokenInput.value : getCSRFToken();
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch(s3tBasePath('/api/s3tables/tables'), {
method: 'POST',
headers: headers,
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
});
const data = await response.json();
@@ -530,7 +521,7 @@ async function deleteS3TablesBucket() {
const bucketArn = document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn;
if (!bucketArn) return;
try {
const response = await fetch(s3tBasePath(`/api/s3tables/buckets?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE' });
const response = await fetch(s3tBasePath(`/api/s3tables/buckets?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to delete bucket');
@@ -561,7 +552,7 @@ async function deleteS3TablesBucketPolicy() {
const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value;
if (!bucketArn) return;
try {
const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE' });
const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to delete policy');
@@ -590,7 +581,7 @@ async function deleteS3TablesTable() {
query.set('version', versionToken);
}
try {
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`), { method: 'DELETE' });
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to delete table');
@@ -621,12 +612,7 @@ async function deleteIcebergTable() {
query.set('version', versionToken);
}
try {
const csrfToken = getCSRFToken();
const requestOptions = { method: 'DELETE' };
if (csrfToken) {
requestOptions.headers = { 'X-CSRF-Token': csrfToken };
}
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`), requestOptions);
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to drop table');
@@ -665,7 +651,7 @@ async function deleteS3TablesTablePolicy() {
const dataNamespace = dataContainer.dataset.namespace || '';
const query = new URLSearchParams({ bucket: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value });
try {
const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`), { method: 'DELETE' });
const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to delete policy');
@@ -872,7 +858,7 @@ async function updateS3TablesTags(resourceArn, tags) {
try {
const response = await fetch(s3tBasePath('/api/s3tables/tags'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ resource_arn: resourceArn, tags: tags })
});
const data = await response.json();
@@ -899,7 +885,7 @@ async function deleteS3TablesTags() {
try {
const response = await fetch(s3tBasePath('/api/s3tables/tags'), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ resource_arn: resourceArn, tag_keys: tagKeys })
});
const data = await response.json();