oasis/src/http.js

141 lines
3.7 KiB
JavaScript

const Koa = require("koa");
const koaStatic = require("koa-static");
const path = require("path");
const mount = require("koa-mount");
/**
* @type function
* @param {{ host: string, port: number, middleware: any[], allowHost: string | null }} input
* @return function
*/
module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa();
assets.use(koaStatic(path.join(__dirname, "assets")));
const app = new Koa();
const validHosts = [];
// All non-GET requests must have a path that doesn't start with `/blob/`.
const isValidRequest = (request) => {
// All requests must use our hostname to prevent DNS rebind attacks.
if (validHosts.includes(request.hostname) !== true) {
console.log(`Invalid HTTP hostname: ${request.hostname}`);
return false;
}
// All non-GET requests must ...
if (request.method !== "GET") {
// ...have a referer...
if (request.header.referer == null) {
console.log("No referer");
return false;
}
try {
const refererUrl = new URL(request.header.referer);
// ...with a valid hostname...
if (validHosts.includes(refererUrl.hostname) !== true) {
console.log(`Invalid referer hostname: ${refererUrl.hostname}`);
return false;
}
// ...and must not originate from a blob path.
if (refererUrl.pathname.startsWith("/blob/")) {
console.log(`Invalid referer path: ${refererUrl.pathname}`);
return false;
}
} catch (e) {
console.log(`Invalid referer URL: ${request.header.referer}`);
return false;
}
}
// If all of the above checks pass, this is a valid request.
return true;
};
app.on("error", (err, ctx) => {
// Output full error objects
console.error(err);
// Avoid printing errors for invalid requests.
if (isValidRequest(ctx.request)) {
err.message = err.stack;
err.expose = true;
}
return null;
});
app.use(mount("/assets", assets));
// headers
app.use(async (ctx, next) => {
const csp = [
"default-src 'none'",
"img-src 'self'",
"form-action 'self'",
"media-src 'self'",
"style-src 'self'",
].join("; ");
// Disallow scripts.
ctx.set("Content-Security-Policy", csp);
// Disallow <iframe> embeds from other domains.
ctx.set("X-Frame-Options", "SAMEORIGIN");
const isBlobPath = ctx.path.startsWith("/blob/");
if (isBlobPath === false) {
// Disallow browsers overwriting declared media types.
//
// This should only happen on non-blob URLs.
// See: https://github.com/fraction/oasis/issues/138
ctx.set("X-Content-Type-Options", "nosniff");
}
// Disallow sharing referrer with other domains.
ctx.set("Referrer-Policy", "same-origin");
// Disallow extra browser features except audio output.
ctx.set("Feature-Policy", "speaker 'self'");
const validHostsString = validHosts.join(" or ");
ctx.assert(
isValidRequest(ctx.request),
400,
`Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.`
);
await next();
});
middleware.forEach((m) => app.use(m));
const server = app.listen({ host, port });
server.on("listening", () => {
const address = server.address();
if (typeof address === "string") {
// This shouldn't happen, but TypeScript was complaining about it.
throw new Error("HTTP server should never bind to Unix socket");
}
if (allowHost !== null) {
validHosts.push(allowHost);
}
validHosts.push(address.address);
if (validHosts.includes(host) === false) {
validHosts.push(host);
}
});
return server;
};