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
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ jobs:
with:
files: |
docker-bake.hcl
targets: releaser-build
targets: |
releaser-build
releaser-test
build:
runs-on: ubuntu-24.04
Expand Down
7 changes: 7 additions & 0 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ target "test-go-redirects" {
provenance = false
}

target "releaser-test" {
context = "hack/releaser"
target = "releaser-test"
output = ["type=cacheonly"]
provenance = false
}

target "dockerfile-lint" {
call = "check"
}
Expand Down
6 changes: 6 additions & 0 deletions hack/releaser/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ RUN --mount=type=bind,target=. \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/releaser .

# releaser-test runs the unit tests for the CloudFront redirect Lambda function.
FROM base AS releaser-test
RUN apk add --no-cache nodejs
RUN --mount=type=bind,target=. \
node --test cloudfront-lambda-redirects.test.js

FROM base AS aws-lambda-invoke
ARG DRY_RUN=false
ARG AWS_REGION
Expand Down
13 changes: 11 additions & 2 deletions hack/releaser/cloudfront-lambda-redirects.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const requestUrl = request.uri.replace(/\/$/, "")

// Preserve the query string (e.g. UTM tags) when issuing a redirect.
const withQuery = (location) => {
if (!request.querystring) {
return location;
}
const separator = location.includes('?') ? '&' : '?';
return location + separator + request.querystring;
};
Comment on lines +9 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I'd personally default to allow-listing this (e.g. just support UTM parameters)

(I can't see any exploitative angles at the moment, would be just in fear of unknown-unknowns)

suggestion: Manipulate this with URL instead of doing raw string concatenation

If location were something like https://foo.com?x=123#some-header, this would yield things like https://foo.com?x=123#some-header&utm_source=....

I'd personally parse location into a URL and request.querystring into a URLSearchParams, then merge the latter into the former's search params, then read its .href to get the final URL.


const redirects = JSON.parse(`{{.RedirectsJSON}}`);
for (let key in redirects) {
const redirectTarget = key.replace(/\/$/, "")
Expand All @@ -18,7 +27,7 @@ exports.handler = (event, context, callback) => {
headers: {
location: [{
key: 'Location',
value: redirects[key],
value: withQuery(redirects[key]),
}],
},
}
Expand All @@ -44,7 +53,7 @@ exports.handler = (event, context, callback) => {
headers: {
location: [{
key: 'Location',
value: newlocation,
value: withQuery(newlocation),
}],
},
}
Expand Down
116 changes: 116 additions & 0 deletions hack/releaser/cloudfront-lambda-redirects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict';

// Tests for cloudfront-lambda-redirects.js.
//
// The function source is a Go text/template (see aws.go) with two
// placeholders that are filled with JSON at build time. These tests render
// the template the same way aws.go does, then load and exercise the handler.
//
// Run with: node --test hack/releaser/cloudfront-lambda-redirects.test.js

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const Module = require('node:module');

const SRC = path.join(__dirname, 'cloudfront-lambda-redirects.js');

const REDIRECTS = {
'/old/page/': '/new/page/',
'/target-with-query/': '/dest/?ref=docs',
};

const REDIRECTS_PREFIXES = [
{ prefix: 'keep/', strip: false },
{ prefix: 'strip/', strip: true },
];

// Render the template the same way getLambdaFunctionZip in aws.go does, then
// evaluate it as a CommonJS module so we can call handler() directly.
function loadHandler() {
const rendered = fs
.readFileSync(SRC, 'utf8')
// Function replacers avoid String.replace's special handling of `$`
// sequences in the replacement, in case a redirect target contains one.
.replace('{{.RedirectsJSON}}', () => JSON.stringify(REDIRECTS))
.replace('{{.RedirectsPrefixesJSON}}', () => JSON.stringify(REDIRECTS_PREFIXES));

const m = new Module(SRC);
m._compile(rendered, SRC);
return m.exports.handler;
}

const handler = loadHandler();

// invoke wraps the callback-style handler in a promise.
function invoke({ uri, querystring = '', accept = 'text/html' }) {
const request = {
uri,
querystring,
headers: { accept: [{ key: 'Accept', value: accept }] },
};
const event = { Records: [{ cf: { request } }] };
return new Promise((resolve, reject) => {
handler(event, {}, (err, result) => {
if (err) return reject(err);
resolve({ result, request });
});
});
}

function locationOf(result) {
return result.headers.location[0].value;
}

test('exact redirect preserves the query string', async () => {
const { result } = await invoke({
uri: '/old/page/',
querystring: 'utm_source=newsletter&utm_campaign=launch',
});
assert.equal(result.status, '301');
assert.equal(locationOf(result), '/new/page/?utm_source=newsletter&utm_campaign=launch');
});

test('exact redirect without a query string is unchanged', async () => {
const { result } = await invoke({ uri: '/old/page/' });
assert.equal(result.status, '301');
assert.equal(locationOf(result), '/new/page/');
});

test('exact redirect appends with & when target already has a query string', async () => {
const { result } = await invoke({
uri: '/target-with-query/',
querystring: 'utm_medium=email',
});
assert.equal(locationOf(result), '/dest/?ref=docs&utm_medium=email');
});

test('prefix redirect (strip) preserves the query string', async () => {
const { result } = await invoke({
uri: '/strip/some/path',
querystring: 'utm_source=x',
});
assert.equal(result.status, '301');
assert.equal(locationOf(result), '/some/path?utm_source=x');
});

test('prefix redirect (no strip) preserves the query string', async () => {
const { result } = await invoke({
uri: '/keep/anything',
querystring: 'utm_source=x',
});
assert.equal(result.status, '301');
assert.equal(locationOf(result), '/?utm_source=x');
});

test('directory rewrite passes the request through with query string intact', async () => {
const { result, request } = await invoke({
uri: '/some/page',
querystring: 'utm_source=x',
});
// No redirect response: the handler forwards the (mutated) request.
assert.equal(result, request);
assert.equal(request.uri, '/some/page/index.html');
assert.equal(request.querystring, 'utm_source=x');
});