mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
/**
|
|
* /plugin-assets serves only a running plugin's browser files: never a path
|
|
* outside its folder (however it is encoded, or through a symlink), never its
|
|
* backend or migrations, and always with a fixed content type and nosniff.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AddressInfo } from "node:net";
|
|
import type { Server } from "node:http";
|
|
import express from "express";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
createPluginAssetsRouter,
|
|
resolvePluginAsset,
|
|
} from "../../../plugins/assets.js";
|
|
|
|
let root = "";
|
|
let server: Server;
|
|
let baseUrl = "";
|
|
const plugins = new Map<string, Record<string, unknown>>();
|
|
|
|
function write(relative: string, content: string) {
|
|
const file = path.join(root, relative);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, content);
|
|
}
|
|
|
|
/** Creates a link, or returns false where the OS will not let us. */
|
|
function link(target: string, at: string, type: "file" | "junction"): boolean {
|
|
try {
|
|
fs.symlinkSync(target, at, type);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
let fileLinks = false;
|
|
let dirLinks = false;
|
|
|
|
beforeAll(async () => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-sec-"));
|
|
write("gizmo/dist/frontend.js", "export function activate() {}");
|
|
write("gizmo/dist/backend.js", "secret()");
|
|
write("gizmo/dist/migrations/snapshot.json", "{}");
|
|
write("gizmo/locales/en.json", "{}");
|
|
write("secret.js", "outside()");
|
|
write("secrets/keys.json", "{}");
|
|
|
|
// A plugin whose manifest puts its frontend at the plugin root, so the
|
|
// backend and migrations sit in the served folder.
|
|
write("flat/frontend.js", "export function activate() {}");
|
|
write("flat/server/main.js", "secret()");
|
|
write(
|
|
"flat/migrations/sqlite/0001_init.sql",
|
|
"CREATE TABLE p_flat_x (id int);",
|
|
);
|
|
|
|
fileLinks = link(
|
|
path.join(root, "secret.js"),
|
|
path.join(root, "gizmo/dist/escape.js"),
|
|
"file",
|
|
);
|
|
dirLinks = link(
|
|
path.join(root, "secrets"),
|
|
path.join(root, "gizmo/dist/linked"),
|
|
"junction",
|
|
);
|
|
|
|
plugins.set("gizmo", {
|
|
id: "gizmo",
|
|
dir: path.join(root, "gizmo"),
|
|
manifest: { id: "gizmo", version: "1.0.0" },
|
|
state: "active",
|
|
});
|
|
plugins.set("flat", {
|
|
id: "flat",
|
|
dir: path.join(root, "flat"),
|
|
manifest: {
|
|
id: "flat",
|
|
version: "1.0.0",
|
|
frontend: "frontend.js",
|
|
backend: "server/main.js",
|
|
},
|
|
state: "active",
|
|
});
|
|
plugins.set("stopped", {
|
|
id: "stopped",
|
|
dir: path.join(root, "gizmo"),
|
|
manifest: { id: "stopped", version: "1.0.0" },
|
|
state: "disabled",
|
|
});
|
|
|
|
const app = express();
|
|
app.use(
|
|
"/plugin-assets",
|
|
createPluginAssetsRouter((id) => plugins.get(id) as never),
|
|
);
|
|
server = await new Promise<Server>((resolve) => {
|
|
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
|
});
|
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
const get = (url: string) => fetch(`${baseUrl}/plugin-assets/${url}`);
|
|
|
|
describe("path traversal", () => {
|
|
it.each([
|
|
"gizmo/../secret.js",
|
|
"gizmo/..%2fsecret.js",
|
|
"gizmo/%2e%2e/secret.js",
|
|
"gizmo/%2e%2e%2f%2e%2e%2fsecret.js",
|
|
"gizmo/..%5csecret.js",
|
|
"gizmo/%2e%2e%5c%2e%2e%5csecret.js",
|
|
"gizmo/frontend.js%00.js",
|
|
"gizmo/locales/..%2f..%2fsecret.js",
|
|
"gizmo/%2fetc%2fpasswd.js",
|
|
])("refuses %s", async (url) => {
|
|
const response = await get(url);
|
|
expect(response.status).toBe(404);
|
|
expect(await response.text()).not.toContain("outside()");
|
|
});
|
|
|
|
it("refuses a symlinked file that points outside the plugin", () => {
|
|
if (!fileLinks) return;
|
|
expect(
|
|
resolvePluginAsset(plugins.get("gizmo") as never, "escape.js"),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("refuses a file inside a symlinked folder that points outside", () => {
|
|
if (!dirLinks) return;
|
|
expect(
|
|
resolvePluginAsset(plugins.get("gizmo") as never, "linked/keys.json"),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("server-only files", () => {
|
|
it("never serves the backend entry, wherever the manifest put it", async () => {
|
|
expect((await get("gizmo/backend.js")).status).toBe(404);
|
|
expect((await get("flat/server/main.js")).status).toBe(404);
|
|
});
|
|
|
|
it("never serves anything under migrations", async () => {
|
|
expect((await get("gizmo/migrations/snapshot.json")).status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("a stopped plugin", () => {
|
|
it("serves nothing, so its bundle cannot be loaded around the shell", async () => {
|
|
expect((await get("stopped/frontend.js")).status).toBe(404);
|
|
expect((await get("gizmo/frontend.js")).status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("headers", () => {
|
|
it("sends a fixed content type and nosniff", async () => {
|
|
const script = await get("gizmo/frontend.js");
|
|
expect(script.headers.get("content-type")).toMatch(/^text\/javascript/);
|
|
expect(script.headers.get("x-content-type-options")).toBe("nosniff");
|
|
|
|
const json = await get("gizmo/locales/en.json");
|
|
expect(json.headers.get("content-type")).toMatch(/^application\/json/);
|
|
expect(json.headers.get("x-content-type-options")).toBe("nosniff");
|
|
|
|
const missing = await get("gizmo/nothing.js");
|
|
expect(missing.headers.get("x-content-type-options")).toBe("nosniff");
|
|
});
|
|
});
|