Fix SSE, KMS not encrypting files
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -784,8 +784,9 @@ def _apply_object_headers(
|
|||||||
metadata: Dict[str, str] | None,
|
metadata: Dict[str, str] | None,
|
||||||
etag: str,
|
etag: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
response.headers["Content-Length"] = str(file_stat.st_size)
|
if file_stat is not None:
|
||||||
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
response.headers["Content-Length"] = str(file_stat.st_size)
|
||||||
|
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
response.headers["Accept-Ranges"] = "bytes"
|
response.headers["Accept-Ranges"] = "bytes"
|
||||||
for key, value in (metadata or {}).items():
|
for key, value in (metadata or {}).items():
|
||||||
@@ -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":
|
||||||
response = Response(_stream_file(path), mimetype=mimetype, direct_passthrough=True)
|
if is_encrypted and hasattr(storage, 'get_object_data'):
|
||||||
logged_bytes = stat.st_size
|
# 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)
|
||||||
|
logged_bytes = stat.st_size
|
||||||
|
etag = storage._compute_etag(path)
|
||||||
else:
|
else:
|
||||||
response = Response(status=200)
|
# 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.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
|
||||||
|
|||||||
38
app/ui.py
38
app/ui.py
@@ -686,9 +686,18 @@ 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)
|
||||||
|
|
||||||
path = storage.get_object_path(bucket_name, key)
|
# Check if object is encrypted
|
||||||
# Use the key as the filename in the zip
|
metadata = storage.get_object_metadata(bucket_name, key)
|
||||||
zf.write(path, arcname=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)
|
||||||
|
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
|
||||||
continue
|
continue
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2850,18 +2850,51 @@
|
|||||||
if (!presignLink?.value) {
|
if (!presignLink?.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(presignLink.value);
|
// 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 {
|
||||||
|
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) {
|
} else {
|
||||||
if (window.showToast) {
|
showMessage({ title: 'Copy Failed', body: 'Unable to copy link to clipboard. Please select the link and copy manually.', variant: 'warning' });
|
||||||
window.showToast('Unable to copy link to clipboard.', 'Copy failed', 'warning');
|
|
||||||
} else {
|
|
||||||
alert('Unable to copy link to clipboard.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user