(aws-ssm): stringListValue returns unsplit list as string instead of list of strings
What is the problem?
When using a StringListParameter imported from an existing string list parameter with StringListParameter.fromStringListParameterName, the resulting value from ipListParam.stringListValue ends up being a single string with the unsplit list in it, instead of the expected list of strings.
My use-case is using the StringListParameter to store a list of IP addresses to be used in a ResourcePolicy, like so:
const ipListParam = new StringListParameter(this, "ip-list", {
stringListValue: ["x.x.x.1", "x.x.x.2"],
parameterName: "ipList",
});
const apiResourcePolicy = new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
principals: [new AnyPrincipal()],
resources: ["execute-api:/*/*/*"],
actions: ["execute-api:Invoke"],
conditions: {
IpAddress: {
"aws:SourceIp": ipListParam.stringListValue,
},
},
}),
],
});
Reproduction Steps
I created a new Typescript CDK project using aws-cdk-lib=2.15.0, and deployed the following stack to create the parameter, REST API Gateway, and resource policy:
Sample 1 (working):
import { aws_apigateway, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { EndpointType } from "aws-cdk-lib/aws-apigateway";
import {
AnyPrincipal,
Effect,
PolicyDocument,
PolicyStatement,
} from "aws-cdk-lib/aws-iam";
import { StringListParameter } from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
export class StringlistTestStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const ipListParam = new StringListParameter(this, "ip-list", {
stringListValue: ["x.x.x.1", "x.x.x.2"],
parameterName: "ipList",
});
ipListParam.applyRemovalPolicy(RemovalPolicy.RETAIN);
const apiResourcePolicy = new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
principals: [new AnyPrincipal()],
resources: ["execute-api:/*/*/*"],
actions: ["execute-api:Invoke"],
conditions: {
IpAddress: {
"aws:SourceIp": ipListParam.stringListValue,
},
},
}),
],
});
const api = new aws_apigateway.RestApi(this, "pudim-api", {
policy: apiResourcePolicy,
endpointTypes: [EndpointType.REGIONAL],
});
const item = api.root.addResource("item");
item.addMethod(
"GET",
new aws_apigateway.HttpIntegration("http://www.pudim.com.br")
);
}
}
Then, I removed ipList from the stack while retaining the parameter, and imported it by its name again.
Sample 2 (not working):
import { aws_apigateway, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { EndpointType } from "aws-cdk-lib/aws-apigateway";
import {
AnyPrincipal,
Effect,
PolicyDocument,
PolicyStatement,
} from "aws-cdk-lib/aws-iam";
import { StringListParameter } from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
export class StringlistTestStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const ipListParam = StringListParameter.fromStringListParameterName(
this,
"ip-list",
"ipList"
);
const apiResourcePolicy = new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
principals: [new AnyPrincipal()],
resources: ["execute-api:/*/*/*"],
actions: ["execute-api:Invoke"],
conditions: {
IpAddress: {
"aws:SourceIp": ipListParam.stringListValue,
},
},
}),
],
});
const api = new aws_apigateway.RestApi(this, "pudim-api", {
policy: apiResourcePolicy,
endpointTypes: [EndpointType.REGIONAL],
});
const item = api.root.addResource("item");
item.addMethod(
"GET",
new aws_apigateway.HttpIntegration("http://www.pudim.com.br")
);
}
}
What did you expect to happen?
The API should be accessible for the IP addresses in the StringListParameter ipList.
I deployed and tested the endpoint resulting from Sample 1, and it worked as expected. The generated resource policy associated with the REST API Gateway was also correct (note the list in aws:SourceIp:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "*"
},
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:eu-central-1:<account>:<api-id>/*/*/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"x.x.x.1",
"x.x.x.2"
]
}
}
}
]
}
What actually happened?
The API was not accessible, and the generated resource policy now contained a comma-separated string of IP addresses.
Generated resource policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "*"
},
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:eu-central-1:<account>:<api-id>/*/*/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "x.x.x.1,x.x.x.2"
}
}
}
]
}
I had the same problem with CDK 1.147.0.
CDK CLI Version
2.15.0 (build 151055e)
Framework Version
?
Node.js Version
v16.14.0
OS
macOS 12.2.1 (21D62)
Language
Typescript
Language Version
Typescript 3.9.7
Other information
When deploying, this is the change to the policy as presented by CDK:
IAM Statement Changes
┌───┬────────────────────┬────────┬────────────────────┬───────────┬──────────────────────────────────────────────────────────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼────────────────────┼────────┼────────────────────┼───────────┼──────────────────────────────────────────────────────────────┤
│ - │ execute-api:/*/*/* │ Allow │ execute-api:Invoke │ AWS:* │ "IpAddress": { │
│ │ │ │ │ │ "aws:SourceIp": "{\"Fn::Split\":[\",\",\"${iplist29786949. │
│ │ │ │ │ │ Value}\"]}" │
│ │ │ │ │ │ } │
├───┼────────────────────┼────────┼────────────────────┼───────────┼──────────────────────────────────────────────────────────────┤
│ + │ execute-api:/*/*/* │ Allow │ execute-api:Invoke │ AWS:* │ "IpAddress": { │
│ │ │ │ │ │ "aws:SourceIp": "{\"Fn::Split\":[\",\",\"{{resolve:ssm:ipL │
│ │ │ │ │ │ ist}}\"]}" │
│ │ │ │ │ │ } │
└───┴────────────────────┴────────┴────────────────────┴───────────┴──────────────────────────────────────────────────────────────┘
Hi @iwt-kschoenrock,
thanks for opening the issue. I was not able to reproduce it, unfortunately.
With this code:
import { App, RemovalPolicy, Stack } from '@aws-cdk/core';
import ec2 = require('@aws-cdk/aws-ec2');
import iam = require('@aws-cdk/aws-iam');
import kms = require('@aws-cdk/aws-kms');
import ssm = require('@aws-cdk/aws-ssm');
import aws_apigateway = require('@aws-cdk/aws-apigateway');
export class TestStack extends Stack {
constructor(app: App, id: string) {
super(app, id);
const ipListParam = ssm.StringListParameter.fromStringListParameterName(
this, "ip-list", "ipList");
const apiResourcePolicy = new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [new iam.AnyPrincipal()],
resources: ["execute-api:/*/*/*"],
actions: ["execute-api:Invoke"],
conditions: {
IpAddress: {
"aws:SourceIp": ipListParam.stringListValue,
},
},
}),
],
});
const api = new aws_apigateway.RestApi(this, "pudim-api", {
policy: apiResourcePolicy,
endpointTypes: [aws_apigateway.EndpointType.REGIONAL],
});
const item = api.root.addResource("item");
item.addMethod("GET",
new aws_apigateway.HttpIntegration("http://www.pudim.com.br"));
}
}
This is the template that I get:
Resources:
pudimapi231E06A0:
Type: AWS::ApiGateway::RestApi
Properties:
EndpointConfiguration:
Types:
- REGIONAL
Name: pudim-api
Policy:
Statement:
- Action: execute-api:Invoke
Condition:
IpAddress:
aws:SourceIp:
Fn::Split:
- ","
- "{{resolve:ssm:ipList}}"
Which I believe is the correct result.
Thanks, Adam
Hi Adam, thanks for taking the time to help out.
It is also the template I got here. However, deploying that through cdk deploy results in an invalid resource policy, because the value for aws:sourceIp is not split. The value for aws:sourceIp should be a list of strings, like:
"aws:SourceIp": ["sourceIpOrCIDRBlock", "sourceIpOrCIDRBlock"]
Instead, the generated policy only contains the text content of the parameter,
"aws:SourceIp": "sourceIpOrCIDRBlock, sourceIpOrCIDRBlock"
, which breaks the resource policy.
Could you please check if your deployment works with that template?
Not sure I understand. Fn::Split returns an array, not a string...? How can it return "sourceIpOrCIDRBlock, sourceIpOrCIDRBlock" then?
That is the question. I had to replace StringListParameter with a StringParameter and do the splitting myself, because the Fn::Split wasn't happening when deploying, and I don't know what else I could do to debug this.
Thanks @iwt-kschoenrock.
@peterwoodworth do you want to try and reproduce this one?
@iwt-kschoenrock I was able to reproduce the issue, on a smaller example.
Here's what I did:
-
Wrote this CDK code:
const ipListParam = new ssm.StringListParameter(this, "ip-list", { stringListValue: ["val1", "val2"], parameterName: "ipList", });Ran
cdk deploy. -
Changed the code to:
const ipListParam = new ssm.StringListParameter(this, "ip-list", { stringListValue: ["val1", "val2"], parameterName: "ipList", }); const importedIpList = ssm.StringListParameter.fromStringListParameterName( this, 'ImportedIpList', "ipList"); new s3.CfnBucket(this, 'CfnBucket', { corsConfiguration: { corsRules: [ { allowedHeaders: importedIpList.stringListValue, allowedMethods: ['GET'], allowedOrigins: ['*'], }, ], }, });Ran
cdk deploy.
And here's what I see in the AWS Console for S3, in the "Cross-origin resource sharing (CORS)" section:
[
{
"AllowedHeaders": [
"val1,val2"
],
"AllowedMethods": [
"GET"
],
"AllowedOrigins": [
"*"
],
"ExposeHeaders": []
}
]
Clearly, it should be "AllowedHeaders": ["val1", "val2"] instead.
So it does look like there's some issue with this, either in the CDK, or in the CloudFormation support.
Changing the Bucket definition to this:
new s3.CfnBucket(this, 'CfnBucket', {
corsConfiguration: {
corsRules: [
{
// allowedHeaders: importedIpList.stringListValue,
allowedHeaders: Fn.split(',', Lazy.string({ produce: () => 'myval1,myval2' })),
allowedMethods: ['GET'],
allowedOrigins: ['*'],
},
],
},
});
Which produces this template:
{
"CfnBucket": {
"Type": "AWS::S3::Bucket",
"Properties": {
"CorsConfiguration": {
"CorsRules": [
{
"AllowedHeaders": {
"Fn::Split": [
",",
"myval1,myval2"
]
}
}
]
}
}
Works correctly:
[
{
"AllowedHeaders": [
"myval1",
"myval2"
],
"AllowedMethods": [
"GET"
],
"AllowedOrigins": [
"*"
],
"ExposeHeaders": []
}
]
So the problem must be in the interaction between CloudFormation Functions like Fn::Split, and dynamic references like {{resolve:ssm:ipList}}.
Is there any progress on this issue? Still running into this on [email protected] -- this is very easy to reproduce. The split string is not correctly added to the template
The relevant part of the generated template can be seen here:
"AllowedHeaders": {
"Fn::Split": [
",",
"{{resolve:ssm:ipList}}"
]
},
We would expect the dynamic reference to be resolved, then have the split be applied. However, this is not what happens
If you test this with splitting by : instead of ,, this is what the allowed headers will resolve to in the S3 console:
"AllowedHeaders": [
"{{resolve",
"ssm",
"ipList}}"
],
This confirms that the split is occurring before the dynamic value is resolved. Since we rely on CloudFormation to keep these values secure, I'm not aware of any workarounds here. I've reported this to CloudFormation to see what they say P68236683
Is there an issue in cfn that tracks this?
Is there any update on this? I have also run into this error - it seems that Fn::Split executes before dymanic value is resolved via {{resolve:ssm}}
Thanks for responding @ShubhamJainSJ, I neglected on posting an update here once one became available. My apologies.
This is part of the response I got:
This is because CFN never stores the actual reference value. CFN only retrieve value during runtime (create/update stack or executechangeset). However, resolve Intrinsic Function happened before runtime (create/update stack or executechangeset).
They dove into a deeper explanation of CloudFormation architecture - Essentially, this is necessary to keep dynamic reference values secure.
As for a workaround...
I would say custom resource is the only way I can think about as a workaround at this stage :(
Could get pretty sloppy.
Hi guys,
I stumbled upon this issue in my own implementation and after a bit of research, I found a workaround:
const parameter = new cdk.CfnParameter(this, 'MyStringListParameter', {
type: 'AWS::SSM::Parameter::Value<List<String>>',
default: "/path/to/my/parameter"
});
To use it as a list:
parameter.valueAsList
Hi guys,
I stumbled upon this issue in my own implementation and after a bit of research, I found a workaround:
const parameter = new cdk.CfnParameter(this, 'MyStringListParameter', { type: 'AWS::SSM::Parameter::Value<List<String>>', default: "/path/to/my/parameter" });To use it as a list:
parameter.valueAsList
As a caution for anyone else using this workaround while the main ability to configure a StringList as an IP allowlist is not available, for some reason today I had trouble with it in the sense that the resource policy was added into the API Gateway, but Postman continued to deny requests until the allowlist was completely removed, deployed and then added back in and deployed again.
I think the CDK problem of this is that the CDK seems to think that using Fn::Split together with a dynamic reference ({{ resolve:ssm:xxxxxxx}}) is actually valid syntax and produces the correct output, which isn't the case. It should import the parameter and then use a Ref to reference it, which should work.
I just ran into the same issue when trying to store a list of IPs (of a NAT gateway setup) to be used as a white list for WAF.
EDIT: Actually using StringListParameter.valueForTypedListParameter() does seem to work properly for my use case.
The same problem is also present when you try to import the CIDR List from SSM to CfnIPSet in CDK/CloudFormation for WAFv2 settings.