Fix SSE, KMS not encrypting files

This commit is contained in:
2025-12-03 10:03:29 +08:00
parent 804f46d11e
commit 453ac6ea30
8 changed files with 136 additions and 32 deletions

View File

@@ -95,6 +95,11 @@ def create_app(
kms_manager = KMSManager(kms_keys_path, kms_master_key_path) kms_manager = KMSManager(kms_keys_path, kms_master_key_path)
encryption_manager.set_kms_provider(kms_manager) encryption_manager.set_kms_provider(kms_manager)
# Wrap storage with encryption layer if encryption is enabled
if app.config.get("ENCRYPTION_ENABLED", False):
from .encrypted_storage import EncryptedObjectStorage
storage = EncryptedObjectStorage(storage, encryption_manager)
app.extensions["object_storage"] = storage app.extensions["object_storage"] = storage
app.extensions["iam"] = iam app.extensions["iam"] = iam
app.extensions["bucket_policies"] = bucket_policies app.extensions["bucket_policies"] = bucket_policies

View File

@@ -54,8 +54,10 @@ class EncryptedObjectStorage:
encryption_config = self.storage.get_bucket_encryption(bucket_name) encryption_config = self.storage.get_bucket_encryption(bucket_name)
if encryption_config and encryption_config.get("Rules"): if encryption_config and encryption_config.get("Rules"):
rule = encryption_config["Rules"][0] rule = encryption_config["Rules"][0]
algorithm = rule.get("SSEAlgorithm", "AES256") # AWS format: Rules[].ApplyServerSideEncryptionByDefault.SSEAlgorithm
kms_key_id = rule.get("KMSMasterKeyID") sse_default = rule.get("ApplyServerSideEncryptionByDefault", {})
algorithm = sse_default.get("SSEAlgorithm", "AES256")
kms_key_id = sse_default.get("KMSMasterKeyID")
return True, algorithm, kms_key_id return True, algorithm, kms_key_id
except StorageError: except StorageError:
pass pass

View File

@@ -91,7 +91,8 @@ class KMSEncryptionProvider(EncryptionProvider):
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes, def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
key_id: str, context: Dict[str, str] | None = None) -> bytes: key_id: str, context: Dict[str, str] | None = None) -> bytes:
"""Decrypt data using envelope encryption with KMS.""" """Decrypt data using envelope encryption with KMS."""
data_key = self.kms.decrypt_data_key(key_id, encrypted_data_key, context) # Note: Data key is encrypted without context (AAD), so we decrypt without context
data_key = self.kms.decrypt_data_key(key_id, encrypted_data_key, context=None)
aesgcm = AESGCM(data_key) aesgcm = AESGCM(data_key)
try: try:

View File

@@ -784,6 +784,7 @@ def _apply_object_headers(
metadata: Dict[str, str] | None, metadata: Dict[str, str] | None,
etag: str, etag: str,
) -> None: ) -> None:
if file_stat is not None:
response.headers["Content-Length"] = str(file_stat.st_size) response.headers["Content-Length"] = str(file_stat.st_size)
response.headers["Last-Modified"] = http_date(file_stat.st_mtime) response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
response.headers["ETag"] = f'"{etag}"' response.headers["ETag"] = f'"{etag}"'
@@ -1779,19 +1780,48 @@ def object_handler(bucket_name: str, object_key: str):
except StorageError as exc: except StorageError as exc:
return _error_response("NoSuchKey", str(exc), 404) return _error_response("NoSuchKey", str(exc), 404)
metadata = storage.get_object_metadata(bucket_name, object_key) metadata = storage.get_object_metadata(bucket_name, object_key)
stat = path.stat() mimetype = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
mimetype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
etag = storage._compute_etag(path) # Check if object is encrypted and needs decryption
is_encrypted = "x-amz-server-side-encryption" in metadata
if request.method == "GET": if request.method == "GET":
if is_encrypted and hasattr(storage, 'get_object_data'):
# Use encrypted storage to decrypt
try:
data, clean_metadata = storage.get_object_data(bucket_name, object_key)
response = Response(data, mimetype=mimetype)
logged_bytes = len(data)
# Use decrypted size for Content-Length
response.headers["Content-Length"] = len(data)
etag = hashlib.md5(data).hexdigest()
except StorageError as exc:
return _error_response("InternalError", str(exc), 500)
else:
# Stream unencrypted file directly
stat = path.stat()
response = Response(_stream_file(path), mimetype=mimetype, direct_passthrough=True) response = Response(_stream_file(path), mimetype=mimetype, direct_passthrough=True)
logged_bytes = stat.st_size logged_bytes = stat.st_size
etag = storage._compute_etag(path)
else: else:
# HEAD request
if is_encrypted and hasattr(storage, 'get_object_data'):
# For encrypted objects, we need to report decrypted size
try:
data, _ = storage.get_object_data(bucket_name, object_key)
response = Response(status=200) response = Response(status=200)
response.headers["Content-Length"] = len(data)
etag = hashlib.md5(data).hexdigest()
except StorageError as exc:
return _error_response("InternalError", str(exc), 500)
else:
stat = path.stat()
response = Response(status=200)
etag = storage._compute_etag(path)
response.headers["Content-Type"] = mimetype response.headers["Content-Type"] = mimetype
logged_bytes = 0 logged_bytes = 0
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag) _apply_object_headers(response, file_stat=path.stat() if not is_encrypted else None, metadata=metadata, etag=etag)
action = "Object read" if request.method == "GET" else "Object head" action = "Object read" if request.method == "GET" else "Object head"
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes}) current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
return response return response

View File

@@ -686,8 +686,17 @@ def bulk_download_objects(bucket_name: str):
# But strictly we should check. Let's check. # But strictly we should check. Let's check.
_authorize_ui(principal, bucket_name, "read", object_key=key) _authorize_ui(principal, bucket_name, "read", object_key=key)
# Check if object is encrypted
metadata = storage.get_object_metadata(bucket_name, key)
is_encrypted = "x-amz-server-side-encryption" in metadata
if is_encrypted and hasattr(storage, 'get_object_data'):
# Decrypt and add to zip
data, _ = storage.get_object_data(bucket_name, key)
zf.writestr(key, data)
else:
# Add unencrypted file directly
path = storage.get_object_path(bucket_name, key) path = storage.get_object_path(bucket_name, key)
# Use the key as the filename in the zip
zf.write(path, arcname=key) zf.write(path, arcname=key)
except (StorageError, IamError): except (StorageError, IamError):
# Skip files we can't read or don't exist # Skip files we can't read or don't exist
@@ -730,13 +739,34 @@ def purge_object_versions(bucket_name: str, object_key: str):
@ui_bp.get("/buckets/<bucket_name>/objects/<path:object_key>/preview") @ui_bp.get("/buckets/<bucket_name>/objects/<path:object_key>/preview")
def object_preview(bucket_name: str, object_key: str) -> Response: def object_preview(bucket_name: str, object_key: str) -> Response:
principal = _current_principal() principal = _current_principal()
storage = _storage()
try: try:
_authorize_ui(principal, bucket_name, "read", object_key=object_key) _authorize_ui(principal, bucket_name, "read", object_key=object_key)
path = _storage().get_object_path(bucket_name, object_key) path = storage.get_object_path(bucket_name, object_key)
metadata = storage.get_object_metadata(bucket_name, object_key)
except (StorageError, IamError) as exc: except (StorageError, IamError) as exc:
status = 403 if isinstance(exc, IamError) else 404 status = 403 if isinstance(exc, IamError) else 404
return Response(str(exc), status=status) return Response(str(exc), status=status)
download = request.args.get("download") == "1" download = request.args.get("download") == "1"
# Check if object is encrypted and needs decryption
is_encrypted = "x-amz-server-side-encryption" in metadata
if is_encrypted and hasattr(storage, 'get_object_data'):
try:
data, _ = storage.get_object_data(bucket_name, object_key)
import io
import mimetypes
mimetype = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
return send_file(
io.BytesIO(data),
mimetype=mimetype,
as_attachment=download,
download_name=path.name
)
except StorageError as exc:
return Response(f"Decryption failed: {exc}", status=500)
return send_file(path, as_attachment=download, download_name=path.name) return send_file(path, as_attachment=download, download_name=path.name)

View File

@@ -2850,18 +2850,51 @@
if (!presignLink?.value) { if (!presignLink?.value) {
return; return;
} }
// Helper function for fallback copy
const fallbackCopy = (text) => {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
success = false;
}
textArea.remove();
return success;
};
let copied = false;
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
try { try {
await navigator.clipboard.writeText(presignLink.value); await navigator.clipboard.writeText(presignLink.value);
copied = true;
} catch (error) {
// Fall through to fallback
}
}
// Fallback for non-secure contexts
if (!copied) {
copied = fallbackCopy(presignLink.value);
}
if (copied) {
copyPresignLink.textContent = 'Copied!'; copyPresignLink.textContent = 'Copied!';
window.setTimeout(() => { window.setTimeout(() => {
copyPresignLink.textContent = copyPresignDefaultLabel; copyPresignLink.textContent = copyPresignDefaultLabel;
}, 1500); }, 1500);
} catch (error) {
if (window.showToast) {
window.showToast('Unable to copy link to clipboard.', 'Copy failed', 'warning');
} else { } else {
alert('Unable to copy link to clipboard.'); showMessage({ title: 'Copy Failed', body: 'Unable to copy link to clipboard. Please select the link and copy manually.', variant: 'warning' });
}
} }
}); });
@@ -3229,11 +3262,7 @@
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
a.remove(); a.remove();
} catch (error) { } catch (error) {
if (window.showToast) { showMessage({ title: 'Download Failed', body: error.message, variant: 'danger' });
window.showToast(error.message, 'Download Failed', 'error');
} else {
alert(error.message);
}
} finally { } finally {
bulkDownloadButton.disabled = false; bulkDownloadButton.disabled = false;
bulkDownloadButton.innerHTML = originalHtml; bulkDownloadButton.innerHTML = originalHtml;

View File

@@ -688,7 +688,9 @@
rotateDoneBtn.classList.remove('d-none'); rotateDoneBtn.classList.remove('d-none');
} catch (err) { } catch (err) {
alert(err.message); if (window.showToast) {
window.showToast(err.message, 'Error', 'danger');
}
rotateSecretModal.hide(); rotateSecretModal.hide();
} finally { } finally {
confirmRotateBtn.disabled = false; confirmRotateBtn.disabled = false;

View File

@@ -116,7 +116,12 @@ def test_path_traversal_in_key(client, signer):
def test_storage_path_traversal(app): def test_storage_path_traversal(app):
storage = app.extensions["object_storage"] storage = app.extensions["object_storage"]
from app.storage import StorageError from app.storage import StorageError, ObjectStorage
from app.encrypted_storage import EncryptedObjectStorage
# Get the underlying ObjectStorage if wrapped
if isinstance(storage, EncryptedObjectStorage):
storage = storage.storage
with pytest.raises(StorageError, match="Object key contains parent directory references"): with pytest.raises(StorageError, match="Object key contains parent directory references"):
storage._sanitize_object_key("folder/../file.txt") storage._sanitize_object_key("folder/../file.txt")