Use ListObjectsV2Command for S3 test instead of raw HTTP signing

This commit is contained in:
Zac Gaetano 2026-04-07 00:42:52 -04:00
parent 1bc30010a4
commit 43aa18f963

View file

@ -3,7 +3,7 @@ const express = require("express");
const multer = require("multer"); const multer = require("multer");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { S3Client, PutObjectCommand, HeadBucketCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require("@aws-sdk/client-s3"); const { S3Client, PutObjectCommand, HeadBucketCommand, DeleteObjectCommand, ListObjectsV2Command, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { Upload } = require("@aws-sdk/lib-storage"); const { Upload } = require("@aws-sdk/lib-storage");
const { NodeHttpHandler } = require("@smithy/node-http-handler"); const { NodeHttpHandler } = require("@smithy/node-http-handler");
@ -442,7 +442,6 @@ app.put("/api/s3/config", requireAdmin, (req, res) => {
}); });
app.post("/api/s3/test", requireAdmin, async (req, res) => { app.post("/api/s3/test", requireAdmin, async (req, res) => {
// Merge submitted values with saved config — secret is often blank (keep existing)
const saved = db.s3Config || {}; const saved = db.s3Config || {};
const testCfg = { const testCfg = {
endpoint: (req.body.endpoint || saved.endpoint || "").trim(), endpoint: (req.body.endpoint || saved.endpoint || "").trim(),
@ -455,77 +454,21 @@ app.post("/api/s3/test", requireAdmin, async (req, res) => {
if (!testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey) if (!testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey)
return res.status(400).json({ success: false, error: "Bucket, Access Key ID, and Secret Key are required to test" }); return res.status(400).json({ success: false, error: "Bucket, Access Key ID, and Secret Key are required to test" });
// Use raw AWS Signature V4 HTTP request — compatible with RustFS/MinIO/generic S3 const testClient = buildS3Client(testCfg);
try { try {
const url = new URL(`${testCfg.endpoint}/${testCfg.bucket}/`); const result = await testClient.send(new ListObjectsV2Command({ Bucket: testCfg.bucket, MaxKeys: 1 }));
const now = new Date(); console.log("[S3 Test] ListObjects OK, KeyCount:", result.KeyCount);
const dateStamp = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; res.json({ success: true, message: "✓ S3 connection confirmed! Credentials are valid and the bucket is accessible." });
const shortDate = dateStamp.slice(0, 8);
const host = url.host;
const method = "GET";
const canonicalUri = `/${testCfg.bucket}/`;
// AWS Signature V4
function hmacSha256(key, data) {
return crypto.createHmac("sha256", key).update(data).digest();
}
function sha256Hex(data) {
return crypto.createHash("sha256").update(data).digest("hex");
}
const credentialScope = `${shortDate}/${testCfg.region}/s3/aws4_request`;
const canonicalHeaders = `host:${host}\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:${dateStamp}\n`;
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
const canonicalRequest = `${method}\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`;
const stringToSign = `AWS4-HMAC-SHA256\n${dateStamp}\n${credentialScope}\n${sha256Hex(canonicalRequest)}`;
const signingKey = hmacSha256(
hmacSha256(
hmacSha256(
hmacSha256(`AWS4${testCfg.secretAccessKey}`, shortDate),
testCfg.region
),
"s3"
),
"aws4_request"
);
const signature = hmacSha256(signingKey, stringToSign).toString("hex");
const authHeader = `AWS4-HMAC-SHA256 Credential=${testCfg.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const fetchUrl = `${testCfg.endpoint}/${testCfg.bucket}/?list-type=2&max-keys=1`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const fetchRes = await fetch(fetchUrl, {
method: "GET",
headers: {
"Host": host,
"x-amz-date": dateStamp,
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
"Authorization": authHeader,
},
signal: controller.signal,
});
clearTimeout(timeout);
const body = await fetchRes.text();
console.log("[S3 Test] Status:", fetchRes.status, "Body:", body.slice(0, 300));
if (fetchRes.ok) {
res.json({ success: true, message: "✓ S3 connection confirmed! Credentials are valid and the bucket is accessible." });
} else if (fetchRes.status === 403) {
res.status(400).json({ success: false, error: "Access denied — check your Access Key and Secret Key" });
} else if (fetchRes.status === 404) {
res.status(400).json({ success: false, error: `Bucket "${testCfg.bucket}" does not exist` });
} else {
res.status(400).json({ success: false, error: `S3 returned ${fetchRes.status}: ${body.slice(0, 200)}` });
}
} catch (err) { } catch (err) {
console.error("[S3 Test] Error:", err.message); console.error("[S3 Test] Full error:", JSON.stringify({ name: err.name, message: err.message, code: err.Code || err.code, status: err.$metadata?.httpStatusCode }));
if (err.name === "AbortError") { let friendly = err.message || "Unknown error";
return res.status(400).json({ success: false, error: "Connection timed out — check the endpoint URL" }); if (err.name === "NoSuchBucket" || err.Code === "NoSuchBucket") friendly = `Bucket "${testCfg.bucket}" does not exist`;
} else if (err.name === "InvalidAccessKeyId" || err.Code === "InvalidAccessKeyId") friendly = "Invalid Access Key ID";
res.status(400).json({ success: false, error: err.message }); else if (err.name === "SignatureDoesNotMatch" || err.Code === "SignatureDoesNotMatch") friendly = "Invalid Secret Access Key";
else if (err.name === "AccessDenied" || err.Code === "AccessDenied") friendly = "Access denied — credentials don't have permission for this bucket";
else if (err.message?.includes("ECONNREFUSED")) friendly = "Connection refused — check endpoint URL";
else if (err.message?.includes("ENOTFOUND")) friendly = "Endpoint not found — check the URL";
res.status(400).json({ success: false, error: friendly });
} }
}); });