Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions crates/fakecloud-cloudformation/src/extras.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,12 +603,26 @@ impl CloudFormationService {
let (bucket, key) = parse_s3_url(url)?;
let mut accounts = self.deps.s3.write();
let state = accounts.get_or_create(account_id);
let body_ref = {
let (body_ref, sse_algorithm) = {
let b = state.buckets.get(&bucket)?;
b.objects.get(&key)?.body.clone()
let o = b.objects.get(&key)?;
(o.body.clone(), o.sse_algorithm.clone())
};
let bytes = state.read_body(&body_ref).ok()?;
String::from_utf8(bytes.to_vec()).ok()
// An SSE-KMS bucket stores an envelope, not the object -- and
// `cdk bootstrap` makes its assets bucket `aws:kms`. The envelope is
// base64 ASCII, so it survives `from_utf8` and passes the non-empty
// check below, producing a "template" with no resources: the stack then
// reports CREATE_COMPLETE having provisioned nothing.
let plaintext = fakecloud_s3::sse::decrypt_body(
self.kms_hook.as_ref(),
account_id,
&bucket,
sse_algorithm.as_deref(),
bytes.to_vec(),
)
.ok()?;
String::from_utf8(plaintext).ok()
}

/// Whether a stack resource's physical resource still exists in its
Expand Down
132 changes: 118 additions & 14 deletions crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3057,14 +3057,38 @@ impl ResourceProvisioner {
.objects
.get(key)
.ok_or_else(|| format!("S3 object s3://{bucket}/{key} does not exist"))?;
object.body.clone()
(object.body.clone(), object.sse_algorithm.clone())
};
// `read_body` consults the body cache (which is owned by the state),
// so re-borrow `state` after dropping the bucket borrow above.
state
.read_body(&body_ref)
self.read_object_body(state, bucket, &body_ref.0, body_ref.1.as_deref())
}

/// Read a stored object body and unwrap it when it is an SSE-KMS envelope.
///
/// Every provisioner read of an S3 object goes through here. An SSE-KMS
/// bucket stores an envelope, not the object, and `cdk bootstrap` makes its
/// assets bucket `aws:kms`: a reader that skips this hands Lambda a base64
/// KMS blob in place of its ZIP, or parses a nested-stack "template" with no
/// resources.
fn read_object_body(
&self,
state: &fakecloud_s3::S3State,
bucket: &str,
body_ref: &fakecloud_persistence::s3::BodyRef,
sse_algorithm: Option<&str>,
) -> Result<Vec<u8>, String> {
let stored = state
.read_body(body_ref)
.map(|b| b.to_vec())
.map_err(|e| format!("S3 read failed: {e}"))
.map_err(|e| format!("S3 read failed: {e}"))?;
fakecloud_s3::sse::decrypt_body(
self.kms_hook.as_ref(),
&self.account_id,
bucket,
sse_algorithm,
stored,
)
}

/// Read a specific object version's bytes. Used when a CFN property
Expand All @@ -3089,24 +3113,21 @@ impl ResourceProvisioner {
.objects
.get(key)
.filter(|o| o.version_id.as_deref() == Some(version_id))
.map(|o| o.body.clone());
.map(|o| (o.body.clone(), o.sse_algorithm.clone()));
from_current
.or_else(|| {
b.object_versions.get(key).and_then(|versions| {
versions
.iter()
.find(|o| o.version_id.as_deref() == Some(version_id))
.map(|o| o.body.clone())
.map(|o| (o.body.clone(), o.sse_algorithm.clone()))
})
})
.ok_or_else(|| {
format!("S3 object s3://{bucket}/{key} version {version_id} does not exist")
})?
};
state
.read_body(&body_ref)
.map(|b| b.to_vec())
.map_err(|e| format!("S3 read failed: {e}"))
self.read_object_body(state, bucket, &body_ref.0, body_ref.1.as_deref())
}

/// Build a canonical Lambda resource-policy statement from a CFN
Expand Down Expand Up @@ -3512,10 +3533,9 @@ impl ResourceProvisioner {
.objects
.get(key)
.ok_or_else(|| format!("S3 object not found: {bucket}/{key}"))?;
let bytes = s3_state
.read_body(&obj.body)
.map_err(|e| format!("Failed to read S3 object body: {e}"))?;
String::from_utf8(bytes.to_vec()).map_err(|e| format!("S3 object is not valid UTF-8: {e}"))
let bytes =
self.read_object_body(s3_state, bucket, &obj.body, obj.sse_algorithm.as_deref())?;
String::from_utf8(bytes).map_err(|e| format!("S3 object is not valid UTF-8: {e}"))
}
}

Expand Down Expand Up @@ -7535,6 +7555,90 @@ mod tests {
assert!(prov.delete_resource(&sr).is_ok());
}

/// Store `plaintext` the way S3 does in an SSE-KMS bucket: as the KMS
/// envelope, with `sse_algorithm = aws:kms`. `version` lands on the
/// current object.
fn put_sse_kms_object(
prov: &ResourceProvisioner,
bucket: &str,
key: &str,
version: &str,
plaintext: &[u8],
) {
let mut ctx = std::collections::HashMap::new();
ctx.insert(
"aws:s3:arn".to_string(),
fakecloud_aws::arn::Arn::s3(bucket).to_string(),
);
let envelope = prov
.kms_hook
.as_ref()
.expect("make_provisioner wires KMS")
.encrypt(
&prov.account_id,
&prov.region,
"aws/s3",
plaintext,
"s3.amazonaws.com",
ctx,
)
.expect("encrypt");
assert_ne!(
envelope.as_bytes(),
plaintext,
"the stored body must be the envelope"
);
let mut accounts = prov.s3_state.write();
let state = accounts.get_or_create(&prov.account_id);
let mut b = fakecloud_s3::S3Bucket::new(bucket, &prov.region, &prov.account_id);
b.objects.insert(
key.to_string(),
fakecloud_s3::S3Object {
key: key.to_string(),
size: envelope.len() as u64,
body: fakecloud_s3::memory_body(bytes::Bytes::from(envelope.into_bytes())),
sse_algorithm: Some("aws:kms".to_string()),
version_id: Some(version.to_string()),
..Default::default()
},
);
state.buckets.insert(bucket.to_string(), b);
}

#[test]
fn current_object_read_decrypts_an_sse_kms_body() {
let prov = make_provisioner();
put_sse_kms_object(&prov, "assets", "code.zip", "v1", b"plaintext body");
assert_eq!(
prov.read_s3_object_bytes("assets", "code.zip").unwrap(),
b"plaintext body"
);
}

#[test]
fn pinned_version_read_decrypts_an_sse_kms_body() {
// A Step Functions `DefinitionS3Location.Version` takes this path.
let prov = make_provisioner();
put_sse_kms_object(&prov, "assets", "sm.json", "v1", br#"{"StartAt":"A"}"#);
assert_eq!(
prov.read_s3_object_version_bytes("assets", "sm.json", "v1")
.unwrap(),
br#"{"StartAt":"A"}"#
);
}

#[test]
fn nested_stack_template_read_decrypts_an_sse_kms_body() {
// CDK uploads nested-stack templates to its `aws:kms` assets bucket.
let prov = make_provisioner();
let template = r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue"}}}"#;
put_sse_kms_object(&prov, "assets", "nested.json", "v1", template.as_bytes());
assert_eq!(
prov.fetch_s3_template("assets", "nested.json").unwrap(),
template
);
}

fn cn_provisioner() -> ResourceProvisioner {
let mut prov = make_provisioner();
prov.region = "cn-north-1".to_string();
Expand Down
5 changes: 3 additions & 2 deletions crates/fakecloud-cloudformation/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,9 @@ pub struct CloudFormationService {
/// real store via `with_s3_store` once it has been built.
s3_store: Arc<dyn S3Store>,
/// The server's KMS hook (persisting minted keys), used by provisioners
/// that report an AWS-managed key for a default-encrypted resource.
kms_hook: Option<Arc<dyn fakecloud_core::delivery::KmsHook>>,
/// that report an AWS-managed key for a default-encrypted resource, and to
/// unwrap SSE-KMS bodies read from S3 (a `TemplateURL`, Lambda code).
pub(crate) kms_hook: Option<Arc<dyn fakecloud_core::delivery::KmsHook>>,
/// Whole-state snapshot persist hooks keyed by service name (see
/// `service_key_for_type`). After a stack op the handler invokes the hook
/// for each touched service so a CFN-provisioned (or CFN-deleted) resource
Expand Down
144 changes: 144 additions & 0 deletions crates/fakecloud-e2e/tests/cfn_lambda.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ mod helpers;

use aws_sdk_cloudformation::types::{Capability, OnFailure, Parameter};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration,
ServerSideEncryptionRule,
};
use helpers::TestServer;

const ROLE_TEMPLATE_FRAGMENT: &str = r#"
Expand Down Expand Up @@ -369,3 +373,143 @@ async fn cfn_update_lambda_function_mutates_handler_and_env() {
// RevisionId rotates whenever the configuration mutates.
assert_ne!(cfg.revision_id().map(String::from), v1_revision);
}

/// Regression: `cdk bootstrap` gives its assets bucket default `aws:kms`
/// encryption, so every asset is stored as a KMS envelope. The provisioner read
/// the stored bytes straight off S3 state, skipping the unwrap the S3 API does,
/// and handed Lambda the envelope in place of the ZIP — which surfaced at invoke
/// time as `ZIP extraction failed: invalid Zip archive: Could not find EOCD`.
/// The sibling test above passes an unencrypted bucket and so never caught it.
#[tokio::test]
async fn cfn_creates_lambda_function_from_sse_kms_s3_code() {
let server = TestServer::start().await;
let cfn = server.cloudformation_client().await;
let lambda = server.lambda_client().await;
let s3 = server.s3_client().await;
let kms = server.kms_client().await;

let key_id = kms
.create_key()
.send()
.await
.expect("create_key")
.key_metadata()
.expect("key metadata")
.key_id()
.to_string();

let bucket = "cfn-lambda-kms-code-bucket";
let key = "code/main.zip";
s3.create_bucket()
.bucket(bucket)
.send()
.await
.expect("create_bucket");
s3.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(
ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::AwsKms)
.kms_master_key_id(&key_id)
.build()
.unwrap(),
)
.build(),
)
.build()
.unwrap(),
)
.send()
.await
.expect("put_bucket_encryption");

let code_bytes = b"def handler(event, context): return {'ok': True}".to_vec();
s3.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(code_bytes.clone()))
.send()
.await
.expect("put_object");

// Guard the premise: if the bucket stopped encrypting, the assertions
// below would pass without exercising the decrypt at all.
let head = s3
.head_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect("head_object");
assert_eq!(
head.server_side_encryption(),
Some(&ServerSideEncryption::AwsKms)
);

let template = template_s3_code("cfn-lambda-kms-role");
cfn.create_stack()
.stack_name("cfn-lambda-kms")
.template_body(template)
.parameters(
Parameter::builder()
.parameter_key("CodeBucket")
.parameter_value(bucket)
.build(),
)
.parameters(
Parameter::builder()
.parameter_key("CodeKey")
.parameter_value(key)
.build(),
)
.capabilities(Capability::CapabilityNamedIam)
.on_failure(OnFailure::Rollback)
.send()
.await
.expect("create_stack");

let described = cfn
.describe_stacks()
.stack_name("cfn-lambda-kms")
.send()
.await
.expect("describe_stacks");
let stack = described.stacks().first().expect("stack");
assert_eq!(stack.stack_status().unwrap().as_str(), "CREATE_COMPLETE");

let func_name = stack
.outputs()
.iter()
.find(|o| o.output_key() == Some("FuncName"))
.and_then(|o| o.output_value())
.expect("FuncName output");

let got = lambda
.get_function()
.function_name(func_name)
.send()
.await
.expect("get_function");
let cfg = got.configuration().expect("configuration");
// The plaintext length, not the (longer) envelope's: before the fix this
// was the base64 KMS blob.
assert_eq!(cfg.code_size(), code_bytes.len() as i64);
assert_eq!(
cfg.code_sha256(),
Some(sha256_b64(&code_bytes).as_str()),
"stored code must hash to the plaintext object, not the envelope"
);
}

/// Base64 SHA-256, matching the `CodeSha256` Lambda reports.
fn sha256_b64(bytes: &[u8]) -> String {
use base64::Engine as _;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
}
Loading
Loading