Tagged template strings in Javascript
Have you ever seen code like this?
const email = request.body.someValue;
const result = await prisma.$queryRaw`SELECT * FROM User WHERE email = ${email}`; At first glance, this may look like a code vulnerable to SQL Injection due to the string interpolation with user input into a raw SQL query. Well, this code is actually safe. How so?
Not just interpolation
If you are familiar with (somewhat) modern Javascript, you probably know how to interpolate strings in it:
`Hello ${username}`. Of course, if you use that for a
SQL Raw Query, you would end up with SQL Injection, but if you take a closer look into the first
code snippet, you will notice that there's a "tag" before the interpolation prisma.$queryRaw`SELECT * FROM User WHERE email = ${email}`;. The
prisma.$queryRaw is a tag function and they have super powers.
Tag functions
Tag functions, or tagged templates, allow you to extend string templates as you wish. Meaning, you can pre-process and manipulate the literal string and the template values. It doesn't even need to return a string.
Like the Prisma example, the values aren't simply interpolated. Instead, Prisma executes a safe query, as long as you use it correctly.
Writing your own tag function
To show how it works in practice, let's write a tag function that will transform all the values to upper case!
// For: upperCaseIt`Hello ${name}, how are you doing ${time}?`
// strings: ["Hello ", ", how are you doing ", "?"]
// values: ["John", "today"]
function upperCaseIt(
strings: TemplateStringsArray,
...values: string[]
): string {
const builder = new Array();
let valueIdx = 0;
for (const str of strings) {
builder.push(str);
if (valueIdx < values.length) {
const value = values[valueIdx++];
builder.push(value.toUpperCase());
}
}
return builder.join("");
}
const name = "John";
const time = "today";
console.log(upperCaseIt`Hello ${name}, how are you doing ${time}?`);
// Hello JOHN, how are you doing TODAY?
Even though this is a simple example, it's possible to make great things with it, like we saw with Prisma.
Another practical example: redacting sensitive data in logs. In production, you don't want to leak user data into your logs, and tagged string templates can help.
function redact(
strings: TemplateStringsArray,
...values: unknown[]
): string {
const builder = new Array();
let valueIdx = 0;
const isDev = process.env.NODE_ENV === "dev";
for (const str of strings) {
builder.push(str);
if (valueIdx < values.length) {
const value = values[valueIdx++];
builder.push(isDev ? value : "[REDACTED]");
}
}
return builder.join("");
}
const email = "[email protected]";
console.log(redact`User ${email} failed to log in`);
// dev: User [email protected] failed to log in
// prod: User [REDACTED] failed to log in
While this is cool, there is an issue. All values are redacted, but we can fix it by adding a wrapper class and a helper function!
class Redactable<T> {
constructor(public value: T) {}
}
function sensitive<T>(value: T): Redactable<T> {
return new Redactable(value);
}
function redact(strings: TemplateStringsArray, ...values: unknown[]): string {
const isDev = process.env.NODE_ENV === "dev";
const builder = new Array();
let valueIdx = 0;
for (const str of strings) {
builder.push(str);
if (valueIdx < values.length) {
const rawValue = values[valueIdx++];
const isRedactable = rawValue instanceof Redactable;
const value = isRedactable ? rawValue.value : rawValue;
const shouldRedact = !isDev && isRedactable;
builder.push(shouldRedact ? "[REDACTED]" : value);
}
}
return builder.join("");
}
const email = "[email protected]";
const errorCount = 3;
console.log(
redact`User ${sensitive(email)} has ${errorCount} failed login attempts`,
);
// dev: User [email protected] has 3 failed login attempts
// prod: User [REDACTED] has 3 failed login attempts
Amazing!!
Of course, this is still just a simplified example. For a production-ready implementation, you
might want to use Symbols because instanceof with multiple realms, modules and global contexts
might not work as expected (as it's customary for Javascript,
expect the unexpected). Also, handle stringification of types so you don't end
up with
[object Object].
You can, but should you?
This is a cool feature, but not known by many. Plus, doing too much implicitly can be extremely confusing.
Once I had to fix some SAST warnings on a project I worked on, and one of the most common ones were Server-Side Request Forgery (SSRF) on HTTP requests with the path built by interpolating strings with user input. Like:
fetch(`https://example.com/v1/users/${userId}`) In the case, while not really SSRF (I guess path manipulation would more correct), if an
attacker controls the value for userId, they can manipulate
the request path itself, for example, if they pass 2/credit-cards the final URL would be
https://example.com/v1/users/2/credit-cards! So, a request
that was supposed to be to get a user by id, now ends up reaching another API.
So I did what any sane developer would do: I wrote an overcomplicated solution: a tagged
function that would encodeURIComponent the values. It didn't even
make it to a code review, because I knew that first, the SAST would not be smart enough to realize
"it is safe", and second, most of the reviewers would have no idea what that did.
So I did what any sane developer would do: I used a regex. Again, the reviewers had no idea what it did, but at least they would be more likely to approve it.
In the end, once more, like any sane developer, I ended up turning that into a Javascript library!
Craft URL
Introducing Craft URL. It's a tagged function that allows you to use string interpolations more safely! Source on Github.
import { urlify, raw } from "craft-url";
// Basic usage
const username = "john doe";
const encodedUri = urlify`/users/${username}`;
console.log(encodedUri); // Output: "/users/john%20doe"
// Query parameters
const filter = "active&new";
const encodedUriWithQuery = urlify`/users?filter=${filter}`;
console.log(encodedUriWithQuery); // Output: "/users?filter=active%26new"
// Using raw values
const baseUrl = "https://api.example.com";
const encodedUriWithRaw = urlify`${raw(baseUrl)}/users/${username}`;
console.log(encodedUriWithRaw); // Output: "https://api.example.com/users/john%20doe"
// Even for database connection strings
const host = "localhost";
const user = "user";
const password = "p@ass/word";
const connStr = urlify`postgresql://${user}:${password}@${host}`;
console.log(connStr); // Output: "postgresql://user:p%40ass%2Fword@localhost"