Skip to content
Merged
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
16 changes: 13 additions & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { config as dotenv } from "dotenv";
dotenv();

import { createClient } from "redis";
import { resolve, join } from "path";
import { resolve, join, sep } from "path";
import { existsSync, readFileSync } from "fs";
import rateLimit from "express-rate-limit";
import { slowDown } from "express-slow-down";
Expand Down Expand Up @@ -338,10 +338,20 @@ export default async function start() {
const dir = req.params[0]; // "script" or "css"
const base = req.params[1]; // e.g. "core"
const ext = req.params[3]; // e.g. "min.js"
const filePath = join("public", dir, `${base}.${ext}`);
// Express decodes captures, so validate before resolving the filename.
if (
(dir !== "script" && dir !== "css") ||
base.startsWith(".") ||
/[/\\\0]/.test(base)
) {
return res.status(404).end();
}
const assetRoot = resolve("public", dir);
const filePath = resolve(assetRoot, `${base}.${ext}`);
if (!filePath.startsWith(assetRoot + sep)) return res.status(404).end();
if (!existsSync(filePath)) return next();
res.set("Cache-Control", "public, max-age=31536000, immutable");
res.sendFile(resolve(filePath));
res.sendFile(filePath);
}
);

Expand Down
105 changes: 105 additions & 0 deletions test/hashed-assets.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
const { expect } = require("chai");
const express = require("express");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const ts = require("typescript");

function request(server, url) {
return new Promise((resolve, reject) => {
http.get({ host: "127.0.0.1", port: server.address().port, path: url }, res => {
let body = "";
res.setEncoding("utf8");
res.on("data", chunk => { body += chunk; });
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body }));
}).on("error", reject);
});
}

describe("hashed asset route", function () {
let directory;
let server;

before(async function () {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "hashed-assets-"));
for (const [name, content] of Object.entries({
"public/script/core.min.js": "window.core = true;",
"public/script/plain.js": "window.plain = true;",
"public/css/all.min.css": "body { color: red; }",
"private.json": "private fixture outside public",
"public-other/private.json": "private fixture in sibling directory",
})) {
const target = path.join(directory, name);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}

// Execute the production route without starting its databases and workers.
const source = fs.readFileSync(path.join(__dirname, "../src/server/index.ts"), "utf8");
const start = source.indexOf(" // Hashed assets");
const end = source.indexOf("\n app.use(", start);
expect(start).to.be.greaterThan(-1);
expect(end).to.be.greaterThan(start);
const app = express();
const context = {
app,
join: path.join,
sep: path.sep,
resolve: (...parts) => path.resolve(directory, ...parts),
existsSync: file => fs.existsSync(path.resolve(directory, file)),
};
new Function(...Object.keys(context), ts.transpileModule(source.slice(start, end), {}).outputText)(...Object.values(context));
app.use(express.static(path.join(directory, "public"), { index: false }));
app.use((_req, res) => res.status(404).end());
server = await new Promise(resolve => {
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
});
});

after(async function () {
if (server) await new Promise(resolve => server.close(resolve));
if (directory) fs.rmSync(directory, { recursive: true, force: true });
});

for (const [url, content] of [
["/script/core.aaaaaaaaaa.min.js", "window.core = true;"],
["/script/plain.aaaaaaaaaa.js", "window.plain = true;"],
["/css/all.aaaaaaaaaa.min.css", "body { color: red; }"],
]) {
it(`serves ${url} with immutable caching`, async function () {
const response = await request(server, url);
expect(response.status).to.equal(200);
expect(response.body).to.equal(content);
expect(response.headers["cache-control"]).to.equal("public, max-age=31536000, immutable");
});
}

for (const dir of ["script", "css"]) {
for (const base of [
"../../private",
"..%2f..%2fprivate",
"%2e%2e%2f%2e%2e%2fprivate",
"..%2F..%2Fprivate",
"..%5c..%5cprivate",
"../../public-other/private",
]) {
it(`rejects traversal through /${dir}/${base}`, async function () {
const response = await request(server, `/${dir}/${base}.aaaaaaaaaa.json`);
expect(response.status).to.equal(404);
expect(response.body).not.to.include("private fixture");
expect(response.headers["cache-control"]).to.equal(undefined);
});
}
}

it("falls through for a missing hashed asset", async function () {
expect((await request(server, "/script/missing.aaaaaaaaaa.js")).status).to.equal(404);
});

it("still serves unhashed static assets", async function () {
const response = await request(server, "/script/plain.js");
expect(response.status).to.equal(200);
expect(response.body).to.equal("window.plain = true;");
});
});
Loading