Skip to content

Commit

Permalink
Merge pull request #1233 from hashicorp/terraform-functions-should-es…
Browse files Browse the repository at this point in the history
…cape-inputs

fix(lib): escape newlines in terraform functions
  • Loading branch information
ansgarm authored Nov 10, 2021
2 parents 7c82308 + e73ea82 commit 62a85c8
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 25 deletions.
23 changes: 22 additions & 1 deletion packages/cdktf/lib/terraform-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { call } from "./tfExpression";
import { IResolvable } from "./tokens/resolvable";
import { TokenMap } from "./tokens/private/token-map";
import { TokenString } from "./tokens/private/encoding";
import { rawString } from ".";

// We use branding here to ensure we internally only handle validated values
// this allows us to catch usage errors before terraform does in some cases
Expand All @@ -11,6 +12,10 @@ type TFValueValidator = (value: any) => TFValue;

type ExecutableTfFunction = (...args: any[]) => IResolvable;

function hasUnescapedDoubleQuotes(str: string) {
return /\\([\s\S])|(")/.test(str);
}

// Validators
function anyValue(value: any): any {
return value;
Expand All @@ -22,8 +27,15 @@ function mapValue(value: any): any {

function stringValue(value: any): any {
if (typeof value !== "string" && !Tokenization.isResolvable(value)) {
throw new Error(`${value} is not a valid number nor a token`);
throw new Error(`'${value}' is not a valid string nor a token`);
}

if (typeof value === "string" && hasUnescapedDoubleQuotes(value)) {
throw new Error(
`'${value}' can not be used as value directly since it has unescaped double quotes in it. To safely use the value please use Fn.rawString on your string.`
);
}

return value;
}

Expand Down Expand Up @@ -1249,4 +1261,13 @@ export class Fn {
public static try(expression: any[]) {
return asAny(terraformFunction("try", listOf(anyValue))(...expression));
}

/**
* Use this function to wrap a string and escape it properly for the use in Terraform
* This is only needed in certain scenarios (e.g., if you have unescaped double quotes in the string)
* @param {String} str
*/
public static rawString(str: string): string {
return asString(rawString(str));
}
}
50 changes: 44 additions & 6 deletions packages/cdktf/lib/tfExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ class TFExpression extends Intrinsic implements IResolvable {
}

if (Array.isArray(resolvedArg)) {
return `[${resolvedArg.join(", ")}]`;
return `[${resolvedArg
.map((_, index) => this.resolveArg(context, arg[index]))
.join(", ")}]`;
}

if (typeof resolvedArg === "object") {
Expand All @@ -29,15 +31,27 @@ class TFExpression extends Intrinsic implements IResolvable {
return resolvedArg;
}

/**
* Escape string removes characters from the string that are not allowed in Terraform or JSON
* It must only be used on non-token values
*/
protected escapeString(str: string) {
return str // Escape double quotes
.replace(/\n/g, "\\n") // escape newlines
.replace(/\${/g, "$$${"); // escape ${ to $${
}

private resolveString(str: string, resolvedArg: any) {
const tokenList = Tokenization.reverseString(str);
const numberOfTokens = tokenList.tokens.length + tokenList.intrinsic.length;

// String literal
if (numberOfTokens === 0) {
return resolvedArg.startsWith('"') && resolvedArg.endsWith('"')
? resolvedArg
: `"${resolvedArg}"`;
return resolvedArg !== `"` &&
resolvedArg.startsWith('"') &&
resolvedArg.endsWith('"')
? this.escapeString(resolvedArg)
: `"${this.escapeString(resolvedArg)}"`;
}

// Only a token reference
Expand All @@ -52,17 +66,41 @@ class TFExpression extends Intrinsic implements IResolvable {
const rightTokens = Tokenization.reverse(right);
const leftValue =
leftTokens.length === 0 ? left : `\${${leftTokens[0]}}`;
leftTokens.length === 0
? this.escapeString(left)
: `\${${leftTokens[0]}}`;
const rightValue =
rightTokens.length === 0 ? right : `\${${rightTokens[0]}}`;
rightTokens.length === 0
? this.escapeString(right)
: `\${${rightTokens[0]}}`;
return `${leftValue}${rightValue}`;
},
})}"`;
}
}

// A string that represents an input value to be escaped
class RawString extends TFExpression {
constructor(private readonly str: string) {
super(str);
}

public resolve() {
const qts = this.isInnerTerraformExpression ? `"` : ``;
return `${qts}${this.escapeString(this.str).replace(/\"/g, '\\"')}${qts}`; // eslint-disable-line no-useless-escape
}

public toString() {
return this.str;
}
}

export function rawString(str: string): IResolvable {
return new RawString(str);
}

class Reference extends TFExpression {
constructor(private identifier: string) {
super(identifier);
Expand Down
83 changes: 83 additions & 0 deletions packages/cdktf/test/functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,34 @@ test("quoted primitives, unquoted functions", () => {
`);
});

test("nested objects and arrays as args", () => {
const app = Testing.app();
const stack = new TerraformStack(app, "test");

new TerraformOutput(stack, "test-output", {
value: Fn.jsonencode({
Statement: [
{
Action: "sts:AssumeRole",
Effect: "Allow",
Principal: { Service: "lambda.amazonaws.com" },
},
],
Version: "2012-10-17",
}),
});

expect(Testing.synth(stack)).toMatchInlineSnapshot(`
"{
\\"output\\": {
\\"test-output\\": {
\\"value\\": \\"\${jsonencode({Statement = [{Action = \\\\\\"sts:AssumeRole\\\\\\", Effect = \\\\\\"Allow\\\\\\", Principal = {Service = \\\\\\"lambda.amazonaws.com\\\\\\"}}], Version = \\\\\\"2012-10-17\\\\\\"})}\\"
}
}
}"
`);
});

test("terraform local", () => {
const app = Testing.app();
const stack = new TerraformStack(app, "test");
Expand Down Expand Up @@ -407,3 +435,58 @@ test("undefined and null", () => {
}"
`);
});

test("throws error on unescaped double quote string inputs", () => {
expect(() => {
const app = Testing.app();
const stack = new TerraformStack(app, "test");
new TerraformOutput(stack, "test-output", {
value: Fn.md5(`"`),
});
Testing.synth(stack);
}).toThrowErrorMatchingInlineSnapshot(
`"'\\"' can not be used as value directly since it has unescaped double quotes in it. To safely use the value please use Fn.rawString on your string."`
);
});

test("throws no error when wrapping unescaped double quotes in Fn.rawString", () => {
const app = Testing.app();
const stack = new TerraformStack(app, "test");
new TerraformOutput(stack, "test-output", {
value: Fn.md5(Fn.rawString(`"`)),
});

expect(Testing.synth(stack)).toMatchInlineSnapshot(`
"{
\\"output\\": {
\\"test-output\\": {
\\"value\\": \\"\${md5(\\\\\\"\\\\\\\\\\\\\\"\\\\\\")}\\"
}
}
}"
`);
});

test("rawString escapes correctly", () => {
const app = Testing.app();
const stack = new TerraformStack(app, "test");
new TerraformLocal(stack, "test", {
default: "abc",
plain: Fn.rawString("abc"),
infn: Fn.base64encode(Fn.rawString("abc")),
quotes: Fn.rawString(`"`),
doublequotes: Fn.rawString(`""`),
template: Fn.rawString("${TEMPLATE}"),
});

const str = Testing.synth(stack);
const json = JSON.parse(str);

const bslsh = `\\`; // a single backslash
expect(json.locals.test).toHaveProperty("default", "abc");
expect(json.locals.test).toHaveProperty("plain", "abc");
expect(json.locals.test).toHaveProperty("infn", '${base64encode("abc")}');
expect(json.locals.test).toHaveProperty("quotes", `${bslsh}"`);
expect(json.locals.test).toHaveProperty("doublequotes", `${bslsh}"${bslsh}"`);
expect(json.locals.test).toHaveProperty("template", "$${TEMPLATE}");
});
Loading

0 comments on commit 62a85c8

Please sign in to comment.