Baobaobaolin.com
date
entry
030
topic
infrastructure
rev
—

A CloudFront Function that returns its own 301 can drop the query string in two places

CloudFront doesn't add the query string to a redirect your function generates. Forget multiValue, or append only the path when adding a slash, and parameters disappear without a sound.

When the first note covered the directory-key trap, it mentioned another route: use CloudFront Functions at the viewer-request stage to rewrite /blog/ to /blog/index.html. I've since moved several sites to that route, and the same function also does the 301 between www and the main domain, because a cache behavior can have only one viewer-request function.

The redirect is a response the function returns itself, so your code builds the Location, and it has to carry the query string too. I ran curl against one of the sites (call it "the value-only function" below): ?tag=a&tag=b came out as ?tag=a, and /blog?utm_source=line came out as /blog/, utm and all gone. This note is about those two places where parameters drop. Four questions, in order: what the query string looks like in the event, how each drop happens, how to test locally, and the fixed function.

The query string in the event

The CloudFront Functions documentation (the Event structure page) says request.querystring is an object with one field per parameter name, holding the value in value. When the same name appears more than once, the occurrences are collapsed into one field with an extra multiValue array holding every value, and the first value appears in both value and multiValue. Part of the documentation's example:

"querystring": {
  "ID": {"value": "42"},
  "NoValue": {"value": ""},
  "querymv": {
    "value": "val1",
    "multiValue": [{"value": "val1"}, {"value": "val2,val3"}]
  }
}

The Event structure page doesn't say directly whether values are percent-decoded. Running curl against the live function, ?q=a%20b&r=%26x%3D1&s=%E5%8C%85 came back from the redirect with all three values still percent-encoded. So when rebuilding the Location, use these strings as they are and don't encode them again, or %20 becomes %2520.

First drop: reading only value keeps one of the repeated parameters

This is how one function builds the query string:

var qs = Object.keys(request.querystring).map(function (k) {
  var v = request.querystring[k];
  return v.value ? k + '=' + v.value : k;
}).join('&');

It reads only value. With ?tag=a&tag=b, value is a and b exists only in multiValue, so the redirect keeps tag=a. A smaller difference: when value is an empty string it outputs empty instead of empty= (the other function does the same for a single empty value). The browser's URLSearchParams, for example, parses both as an empty string, but literally they aren't the same URL, and whether a backend distinguishes them is worth checking.

The function on the other site handles multiValue, and the same URL redirects to ?tag=a&tag=b. Both rebuild the query when changing hosts; one reads only value, the other also reads multiValue. Their directory handling differs too, which is next.

Second drop: the trailing-slash redirect leaves the query behind

The same function returns a 301 to /blog/ for a path whose last segment has no . and no trailing slash, such as /blog. It guesses "directory" from the absence of a dot:

return { statusCode: 301, statusDescription: 'Moved Permanently',
         headers: { location: { value: uri + '/' } } };

The Location is only the path, with no query attached. The www branch does carry the query, so when the two redirects chain, the first hop keeps it and the second drops it:

$ curl -sSIL 'https://www.<site>/blog?utm_source=line' | grep -iE '^(HTTP|location)'
HTTP/2 301
location: https://<site>/blog?utm_source=line
HTTP/2 301
location: /blog/
HTTP/2 200

After two hops the utm tag is gone. If analytics relies on it to identify the source, campaign attribution may suffer, and with no other source information the visit may even count as direct traffic; I haven't checked actual reports. How much that matters depends on how many shared links use the no-slash form, which I haven't measured.

The other function made a different choice: /blog isn't redirected; request.uri is rewritten to /blog/index.html and handed on, and live it answers 200. The address bar doesn't change and no query is lost. The cost is that the same content lives at least at /blog and /blog/, and the canonical tag can only state a preference; the search engine makes the final choice.

Running the handler locally

Both problems are logic errors, so you can test them without AWS. The function file holds only a handler, with no export; wrap it with new Function and you can call it in Node, building the event in the documented format. Save the script as .mjs and run it with node, with the function file in the same directory and the host changed to a domain that function recognises:

import { readFileSync } from "node:fs";
const load = (f) => new Function(readFileSync(f, "utf8") + "\nreturn handler;")();
const ev = (host, uri, qs = {}) => ({ version: "1.0",
  context: { eventType: "viewer-request" }, viewer: { ip: "203.0.113.1" },
  request: { method: "GET", uri, querystring: qs,
             headers: { host: { value: host } }, cookies: {} } });

const h = load("www-redirect.js");
console.log(h(ev("www.example.com", "/blog/",
  { tag: { value: "a", multiValue: [{ value: "a" }, { value: "b" }] } })));

Four cases on both functions (the multiValue-aware one redirects to www, so the hosts are swapped):

CaseValue-only functionmultiValue-aware function
Host change, ?tag=a&tag=b?tag=a?tag=a&tag=b
Main host, /blog?utm_source=line301 → /blog/rewritten to /blog/index.html
Host change, /blog?utm_source=line301, utm kept (lost on the next hop)301, utm kept
Empty value ?empty=?empty?empty

The local results match the live curl output. Two limits are worth stating. First, Node isn't the CloudFront Functions runtime (these two are set to cloudfront-js-1.0 and 2.0) and the supported syntax differs, so passing locally doesn't mean it will run once uploaded; before publishing you still run aws cloudfront test-function in the real runtime (this note only tests locally and observes live HTTP; it calls no AWS management API). Second, parameter order: locally these parameter names list in the order I created them, but live, ?q=…&r=…&s=… came back as ?s=…&r=…&q=…, reversed. The documentation doesn't promise an order, so a backend that depends on it needs its own handling.

The fixed function

Two changes: move query building into one function that reads multiValue and always writes empty values as key=; and merge the host change and the trailing slash into a single hop straight to the final URL.

function qs(request) {
  var parts = [];
  Object.keys(request.querystring).forEach(function (k) {
    var e = request.querystring[k];
    (e.multiValue ? e.multiValue : [e]).forEach(function (v) {
      parts.push(v.value === '' ? k + '=' : k + '=' + v.value);
    });
  });
  return parts.length ? '?' + parts.join('&') : '';
}
function handler(event) {
  var request = event.request;
  var host = request.headers.host && request.headers.host.value;
  var uri = request.uri;
  var needsSlash = !uri.endsWith('/') &&
                   uri.lastIndexOf('.') < uri.lastIndexOf('/') + 1;
  if (host === 'www.example.com' || needsSlash) {
    return { statusCode: 301, statusDescription: 'Moved Permanently',
             headers: { location: { value:
               'https://example.com' + uri + (needsSlash ? '/' : '') + qs(request) } } };
  }
  if (uri.endsWith('/')) request.uri = uri + 'index.html';
  return request;
}

Through the same local script: ?tag=a&tag=b keeps both, and /blog?utm_source=line goes in one hop to https://example.com/blog/?utm_source=line from either host. Note that it's broader than before: any host that needs a slash gets sent to the main domain, including requests to the default CloudFront domain. I think that's right, but check first that no other domain is attached to the same distribution, and that no path must stay slash-less: guessing directories by the dot means extensionless files under /.well-known/acme-challenge/, or API paths, would get a slash added and need excluding.

This runs at viewer request, so a mistake affects every request that uses that cache behavior, which is the whole site when the distribution has only the default behavior. The local script makes a good first gate before publishing because it needs no credentials and runs in milliseconds; test-function is the second gate, and publish-function comes only after both pass, followed by walking the redirect chain with curl. I haven't published this fix yet.

If you remember one thing

When a CloudFront Function returns a 301, you build the whole URL yourself, and CloudFront won't add the query string for you. The two URLs at the top that lost their parameters reproduce in a dozen-line local script, as long as the event follows the documented format, including multiValue for repeated parameters.

Revision history

  1. First published