Fix file access, XSS and access-control bugs from a security scan

- Media rows can no longer be created through the CRUD API, updates only
  take description fields on the caller's own row, and every media read
  or delete goes through mediaFilePath, which refuses paths outside
  DATA_DIR or inside the database folder.
- Uploads strip the file name and extension to safe characters and keep
  only title, description and summary from the request's media object.
- Folder imports no longer follow symlinks (walkRegularFiles), and the
  source path is quoted in the computer's shell.
- User and bot files are served with nosniff, and HTML, SVG, XML and
  scripts are sandboxed by CSP.
- Sandbox cookies are kept to the computer's prefix, and websocket
  upgrades on /ws and the computer proxy must come from the app's own
  address (README and Help note nginx's Host header).
- user_types CRUD writes go through the assignment matrix; members only
  read their own user record; read-only users can read CRUD again;
  grabUsers builds valid SQL for array search terms and bad pages; an
  admin media DELETE needs an id; read-only users can't delete media.
- Rate limits and the audit log use the connection's address, trusting
  forwarded headers only from a proxy, and login limits are split per
  address, per account from that address and per account overall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 03:38:07 +01:00
co-authored by Claude Opus 5.5
parent 2e8e8de976
commit cdb93ac1bc
42 changed files with 817 additions and 204 deletions
+4
View File
@@ -126,6 +126,10 @@ point that at `http://<this machine>:7227`:
sign-in cookies `Secure`; Caddy does this by default, and nginx needs
`proxy_set_header X-Forwarded-Proto $scheme;`.
- Pass `/ws` through as well as ordinary requests, including the upgrade.
- Keep the address the browser used in `Host` (or send it as
`X-Forwarded-Host`). Live updates and the computers' terminals only accept
a websocket from the app's own address; Caddy and Cloudflare Tunnel keep
it, and nginx needs `proxy_set_header Host $host;`.
- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
the installable app, offline support, Web Push notifications and voice
recording stay off until the address is HTTPS.
+4 -4
View File
@@ -1,11 +1,10 @@
import path from "node:path";
import mediaFilePath from "@/src/utils/media-file-path";
import { AppData } from "@/src/data/app-data";
import grabMessagesMedia, {
type MessageMediaRow,
} from "@/src/functions/backend/db/bots/grab-messages-media";
import type { HarnessContentPart } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import describeAttachment from "./describe-attachment";
type Params = {
@@ -74,9 +73,10 @@ async function fileToParts({ file }: { file: MessageMediaRow }): Promise<Harness
return [{ type: "text", text: `[Attached video ${label}: videos can't be read yet.]` }];
}
const disk = Bun.file(path.join(grabDirNames().DATA_DIR, file.media_write_path || ""));
const absolute = mediaFilePath({ write_path: file.media_write_path });
const disk = absolute ? Bun.file(absolute) : null;
if (!file.media_write_path || !(await disk.exists())) {
if (!disk || !(await disk.exists())) {
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
}
@@ -1,5 +1,5 @@
import mediaFilePath from "@/src/utils/media-file-path";
import AppError from "@/src/utils/app-error";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { StorageScope } from "@/src/utils/storage-access";
import { canReachScope } from "@/src/utils/storage-access";
import grabBotFile from "./grab-bot-file";
@@ -42,12 +42,13 @@ export default async function copyBotFile({
const file = await grabBotFile({ scopes, file_id });
if (!file?.id || !file.media?.media_write_path) {
const absolute = mediaFilePath({ write_path: file?.media?.media_write_path });
if (!file?.id || !absolute) {
throw new AppError(`That file isn't in your files.`);
}
const { DATA_DIR } = grabDirNames();
const source = await Bun.file(`${DATA_DIR}/${file.media.media_write_path}`).arrayBuffer();
const source = await Bun.file(absolute).arrayBuffer();
return await recordBotFile({
bot_id,
@@ -1,3 +1,4 @@
import mediaFilePath from "@/src/utils/media-file-path";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
import { rmSync } from "node:fs";
@@ -24,7 +25,6 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
throw new AppError(`That file isn't in your files.`);
}
const { DATA_DIR } = grabDirNames();
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
const absolute = path.join(root, file.path || "");
@@ -55,12 +55,10 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
values: [file.media_id],
}).then((res) => res.payload?.[0]);
if (media?.media_write_path) {
rmSync(path.join(DATA_DIR, media.media_write_path), { force: true });
}
for (const write_path of [media?.media_write_path, media?.media_thumbnail_write_path]) {
const absolute = mediaFilePath({ write_path });
if (media?.media_thumbnail_write_path) {
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), { force: true });
if (absolute) rmSync(absolute, { force: true });
}
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
@@ -1,8 +1,8 @@
import mediaFilePath from "@/src/utils/media-file-path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import { logError } from "@/src/functions/backend/logger";
import grabDirNames from "@/src/utils/grab-dir-names";
import recordBotFile from "./record-bot-file";
type Params = {
@@ -26,14 +26,15 @@ export default async function storeChatAttachments({
}: Params) {
if (!media.length) return;
const { DATA_DIR } = grabDirNames();
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
for (const file of media) {
try {
if (!file.media_write_path) continue;
const absolute = mediaFilePath({ write_path: file.media_write_path });
const source = Bun.file(`${DATA_DIR}/${file.media_write_path}`);
if (!absolute) continue;
const source = Bun.file(absolute);
if (!(await source.exists())) continue;
+7 -14
View File
@@ -1,11 +1,10 @@
import mediaFilePath from "@/src/utils/media-file-path";
import _ from "lodash";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
import type { MediaParadigmType, TableType, User } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import BunSQLite from "@moduletrace/bun-sqlite";
import { rmSync } from "fs";
import path from "path";
type Params = {
id?: string | number;
@@ -22,7 +21,6 @@ type Params = {
export default async function deleteMedia(params: Params) {
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
const { DATA_DIR } = grabDirNames();
let media_to_delete = new Set<number>();
@@ -87,18 +85,13 @@ export default async function deleteMedia(params: Params) {
continue;
}
if (target_media.media_write_path) {
rmSync(
path.join(DATA_DIR, target_media.media_write_path),
{ force: true },
);
}
for (const write_path of [
target_media.media_write_path,
target_media.media_thumbnail_write_path,
]) {
const absolute = mediaFilePath({ write_path });
if (target_media.media_thumbnail_write_path) {
rmSync(
path.join(DATA_DIR, target_media.media_thumbnail_write_path),
{ force: true },
);
if (absolute) rmSync(absolute, { force: true });
}
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
@@ -1,11 +1,10 @@
import path from "node:path";
import mediaFilePath from "@/src/utils/media-file-path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { extensionOf } from "@/src/functions/backend/db/bot-files/media-type-for-file";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import type { MediaParadigm } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabDirNames from "@/src/utils/grab-dir-names";
export type PictureTarget = "bot" | "workspace";
@@ -102,13 +101,13 @@ export default async function copyMediaAsPicture({
* The image's bytes, read from where `media` keeps them.
*/
async function readMediaBytes({ media }: { media: BUN_SQLITE_COBBLE_MEDIA }) {
const { DATA_DIR } = grabDirNames();
const absolute = mediaFilePath({ write_path: media.media_write_path });
if (!media.media_write_path) {
if (!absolute) {
throw new AppError(`Its file is missing on the server. Save it again.`);
}
const file = Bun.file(path.join(DATA_DIR, media.media_write_path));
const file = Bun.file(absolute);
if (!(await file.exists())) {
throw new AppError(`Its file is missing on the server. Save it again.`);
+27 -30
View File
@@ -57,22 +57,17 @@ export default async function grabUsers(
}
if (query?.search_term_user_type) {
const user_type_terms = (
Array.isArray(query.search_term_user_type)
? query.search_term_user_type
: [query.search_term_user_type]
).filter(Boolean);
sql += ` AND EXISTS (\n`;
sql += ` SELECT 1 FROM user_types WHERE user_id = u.id AND user_type LIKE`;
if (Array.isArray(query.search_term_user_type)) {
sql += `(`;
for (let i = 0; i < query.search_term_user_type.length; i++) {
const ut = query.search_term_user_type[i];
sql += ` ?`;
values.push(`%${ut}%`);
}
sql += `)`;
} else {
sql += ` ?`;
values.push(`%${query.search_term_user_type}%`);
}
sql += ` SELECT 1 FROM user_types WHERE user_id = u.id AND (${
user_type_terms.map(() => "user_type LIKE ?").join(" OR ") || "0"
})\n`;
values.push(...user_type_terms.map((ut) => `%${ut}%`));
sql += ` )\n`;
} else if (query?.target_user_type) {
sql += ` AND EXISTS (
@@ -94,21 +89,21 @@ export default async function grabUsers(
const match_value = params?.exact_match ? "=" : "LIKE";
if (Array.isArray(term.value)) {
sql += ` AND (`;
for (let i = 0; i < term.value.length; i++) {
const term_value = term.value[i];
if (!term_value) continue;
const match_value_param = params?.exact_match
? term_value
: `%${term_value.replace(/ /g, "%")}%`;
const term_values = term.value.filter(Boolean);
sql += ` u.${term.field} ${isId ? "=" : match_value} ?`;
if (i < term.value.length - 1) {
sql += ` OR`;
if (!term_values[0]) continue;
sql += ` AND (${term_values
.map(() => `u.${term.field} ${isId ? "=" : match_value} ?`)
.join(" OR ")})\n`;
for (const term_value of term_values) {
values.push(
isId || params?.exact_match
? term_value
: `%${term_value.replace(/ /g, "%")}%`,
);
}
values.push(isId ? term_value : match_value_param);
}
sql += ` )\n`;
} else {
const match_value_param = params?.exact_match
? term.value
@@ -121,8 +116,10 @@ export default async function grabUsers(
let sql_limit = ``;
sql_limit += `LIMIT ${LIMIT}\n`;
if (query?.page) {
const offset = LIMIT * (query.page - 1);
const page = Math.floor(Number(query?.page));
if (page > 1) {
const offset = LIMIT * (page - 1);
sql_limit += `OFFSET ${offset}\n`;
}
@@ -16,6 +16,7 @@ import grabDirNames from "@/src/utils/grab-dir-names";
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
import { existsSync, mkdirSync } from "fs";
import path from "path";
import _ from "lodash";
import sharp, { type Sharp } from "sharp";
import canExtractText from "@/src/utils/can-extract-text";
import capTextContent from "@/src/utils/cap-text-content";
@@ -81,8 +82,14 @@ export default async function uploadAndRecordMedia({
media_text_content: passed_media_text_content,
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>> {
const now = Date.now();
let target_file_name = `${media_name || now}`;
let target_file_thumbnail_name = `${media_name || now}-thumb`;
/**
* The name becomes part of a path on disk, so only letters, digits,
* `-` and `_` are kept: a name like `../../db/cobble` must never
* write outside the user's media folder.
*/
const safe_name = (media_name || "").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 120);
let target_file_name = `${safe_name || now}`;
let target_file_thumbnail_name = `${safe_name || now}-thumb`;
const { USER_MEDIA_RELATIVE_DIR: MEDIA_RELATIVE_DIR, DATA_DIR } =
grabDirNames({
@@ -127,6 +134,8 @@ export default async function uploadAndRecordMedia({
* a transparent PNG stays a PNG (`imageOutputFormat`). Every other file
* keeps its own extension.
*/
media_ext = media_ext?.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 16) || undefined;
const image_output = media_type == "image" ? imageOutputFormat({ ext: media_ext }) : null;
const stored_ext = image_output ? image_output.ext : media_ext;
@@ -261,7 +270,11 @@ export default async function uploadAndRecordMedia({
}
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
...media,
/**
* Only what describes the file: an `id` or a path in `media`
* would point the row at another one.
*/
..._.pick(media || {}, ["title", "description", "summary"]),
user_id: Number(user.id),
/**
* NULL, not 0, when not primary: the (user_id, is_primary) unique
+7 -7
View File
@@ -1,3 +1,5 @@
import clientIP from "@/src/utils/client-ip";
import { grabRequestPeerIP } from "@/src/utils/request-peer-ip";
import BunSQLite from "@moduletrace/bun-sqlite";
import { LogCategories } from "@/src/dict/log-categories";
@@ -8,13 +10,11 @@ export function grabRequestIP(req?: Request): string | undefined {
return undefined;
}
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",").shift()?.trim();
}
return req.headers.get("x-real-ip") || undefined;
return clientIP({
peer: grabRequestPeerIP({ req }),
forwarded_for: req.headers.get("x-forwarded-for"),
real_ip: req.headers.get("x-real-ip"),
});
}
export type LogDetail = {
@@ -3,6 +3,7 @@ import computerProxyRequestHeaders from "@/src/utils/computer-proxy-request-head
import computerProxyResponseHeaders from "@/src/utils/computer-proxy-response-headers";
import type { WebSocketSession } from "@/src/websocket/types";
import computerProxyTarget from "./computer-proxy-target";
import isSameOriginUpgrade from "@/src/utils/is-same-origin-upgrade";
type Params = {
req: Request;
@@ -25,6 +26,8 @@ export default async function handleComputerProxy({ req, server }: Params) {
const headers = computerProxyRequestHeaders({ headers: req.headers, url, prefix });
if (req.headers.get("upgrade")?.toLowerCase() == "websocket") {
if (!isSameOriginUpgrade({ req })) return new Response("Forbidden", { status: 403 });
const protocols = (req.headers.get("sec-websocket-protocol") || "")
.split(",")
.map((p) => p.trim())
@@ -1,10 +1,9 @@
import grabDirNames from "@/src/utils/grab-dir-names";
import mediaFilePath from "@/src/utils/media-file-path";
import servedFileHeaders from "@/src/utils/served-file-headers";
import { existsSync } from "node:fs";
import path from "node:path";
import userAuth from "../../backend/auth/user-auth";
import grabUserMedia from "../../backend/db/media/grab-user-media";
const { DATA_DIR } = grabDirNames();
type Params = {
req: Request;
@@ -45,16 +44,16 @@ export default async function handleMediaServer({
throw new Error(`Media record not found!`);
}
const media_location = thumbnail
const full_media_location = mediaFilePath({
write_path: thumbnail
? media.media_thumbnail_write_path
: media.media_write_path;
: media.media_write_path,
});
if (!media_location) {
if (!full_media_location) {
throw new Error(`No media location found!`);
}
const full_media_location = path.join(DATA_DIR, media_location);
if (!existsSync(full_media_location)) {
throw new Error(`Media does not exist!`);
}
@@ -63,6 +62,7 @@ export default async function handleMediaServer({
const etag = `"${file.size}-${file.lastModified}"`;
const headers = new Headers({
...servedFileHeaders({ content_type: file.type }),
ETag: etag,
/**
* Private: the response depends on who is signed in, so no shared
+6 -18
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { z } from "zod";
@@ -12,6 +12,7 @@ import type { StorageScope } from "@/src/utils/storage-access";
import type { HarnessTool } from "../../types";
import downloadComputerFile, { shellQuote } from "./download-computer-file";
import runComputerCommand from "./run-computer-command";
import walkRegularFiles from "@/src/utils/walk-regular-files";
type Params = {
/**
@@ -260,7 +261,7 @@ async function sourceKind({
}): Promise<"file" | "folder" | "missing"> {
const result = await runComputerCommand({
base_url,
command: `d="$HOME/${source}"; if [ -d "$d" ]; then echo folder; elif [ -f "$d" ]; then echo file; else echo missing; fi`,
command: `d="$HOME"/${shellQuote({ text: source })}; if [ -d "$d" ]; then echo folder; elif [ -f "$d" ]; then echo file; else echo missing; fi`,
timeout_ms: AppData.ComputerSearchTimeoutMs,
signal,
});
@@ -325,7 +326,7 @@ async function readFolderArchive({
const result = await runComputerCommand({
base_url,
command: [
`d="$HOME/${source}"`,
`d="$HOME"/${shellQuote({ text: source })}`,
`t=$(mktemp /tmp/cobble-import-XXXXXX.tgz)`,
`tar -czf "$t" -C "$(dirname "$d")" "$(basename "$d")" || { rm -f "$t"; echo FAILED; exit 0; }`,
`s=$(wc -c < "$t")`,
@@ -394,7 +395,7 @@ async function unpackArchive({
const base = archiveRoot({ dir });
for (const entry of walkFiles({ dir: base })) {
for (const entry of walkRegularFiles({ dir: base })) {
const relative = path.relative(base, entry).split(path.sep).join("/");
const row = await recordBotFile({
bot_id,
@@ -428,26 +429,13 @@ function archiveRoot({ dir }: { dir: string }) {
const entries = readdirSync(dir).filter((name) => name != "import.tgz");
const only = entries.at(0);
if (entries.length == 1 && only && statSync(path.join(dir, only)).isDirectory()) {
if (entries.length == 1 && only && lstatSync(path.join(dir, only)).isDirectory()) {
return path.join(dir, only);
}
return dir;
}
/**
* Every file under a directory.
*/
function* walkFiles({ dir }: { dir: string }): Generator<string> {
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
const stats = statSync(full);
if (stats.isDirectory()) yield* walkFiles({ dir: full });
else if (stats.isFile()) yield full;
}
}
/**
* The path the tool was given, made relative to the computer's home and
* refused if it leaves it.
+4 -5
View File
@@ -1,7 +1,7 @@
import mediaFilePath from "@/src/utils/media-file-path";
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
import grabDirNames from "@/src/utils/grab-dir-names";
import readMedia from "@/src/harness/media/read-media";
import type { HarnessTool } from "../../types";
import { fileRef, type StorageToolContext } from "./storage-tool-context";
@@ -54,12 +54,11 @@ export default function readFileTool(context: StorageToolContext): HarnessTool<A
path: args.path,
});
if (!file?.id || !file.media?.media_write_path) {
const absolute = mediaFilePath({ write_path: file?.media?.media_write_path });
if (!file?.id || !absolute) {
return `No file matches that. Use list_files to see what you have.`;
}
const { DATA_DIR } = grabDirNames();
const absolute = `${DATA_DIR}/${file.media.media_write_path}`;
const ext = (file.name || "").split(".").pop()?.toLowerCase() || "";
if (TextExtensions.includes(ext)) {
+1 -1
View File
@@ -36,7 +36,7 @@ The proxy should reach the app at \`http://<this machine>:7227\` (or the port gi
- **You opened the plain HTTP address.** Use the HTTPS address your proxy serves. \`http://\` will always be treated as unsafe.
- **The certificate warning comes from the proxy.** It is using a self-signed or expired certificate. Fix that on the proxy: Cobble never sees the certificate.
- **Sign-in doesn't stick, or downloads fail.** The proxy is not passing \`X-Forwarded-Proto\` (see above), or it is dropping cookies.
- **WebSocket updates don't arrive.** The proxy must pass the upgrade on \`/ws\` as well as ordinary requests.`,
- **WebSocket updates don't arrive.** The proxy must pass the upgrade on \`/ws\` as well as ordinary requests, and keep the address the browser used in \`Host\` (or send it as \`X-Forwarded-Host\`): a websocket from any other address is refused. With nginx add \`proxy_set_header Host $host;\`.`,
},
{
id: HelpSignInTopicId,
@@ -0,0 +1,15 @@
/**
* The only `media` columns writable through the CRUD API: what describes a
* file, never where it lives (`media_write_path`,
* `media_thumbnail_write_path`), whose it is (`user_id`) or what it is
* (`media_type`, `mime_type`). Those are set by the upload routes alone,
* since a path written here would be served or deleted as is.
*/
export const MediaUpdatableFields = [
"title",
"description",
"summary",
"text_content",
"is_primary",
"archived",
] as const;
+18 -12
View File
@@ -1,3 +1,4 @@
import mediaFilePath from "@/src/utils/media-file-path";
import _ from "lodash";
import type { AdminCrudAPIParams, TableType } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
@@ -9,8 +10,6 @@ import type {
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import { rmSync } from "fs";
import path from "path";
import grabDirNames from "@/src/utils/grab-dir-names";
export default async function (
params: AdminCrudAPIParams,
@@ -29,6 +28,17 @@ export default async function (
};
}
/**
* One row at a time: without an id an empty query would select, and
* delete, every media row there is.
*/
if (!id) {
return {
success: false,
msg: `No media id passed`,
};
}
let query = _.merge<
ServerQueryParam<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>,
ServerQueryParam<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>
@@ -50,22 +60,18 @@ export default async function (
};
}
const { DATA_DIR } = grabDirNames();
for (let i = 0; i < media_to_delete.payload.length; i++) {
const media = media_to_delete.payload[i];
if (!media?.id) continue;
if (media.media_write_path) {
rmSync(path.join(DATA_DIR, media.media_write_path), {
force: true,
});
}
for (const write_path of [
media.media_write_path,
media.media_thumbnail_write_path,
]) {
const absolute = mediaFilePath({ write_path });
if (media.media_thumbnail_write_path) {
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), {
force: true,
});
if (absolute) rmSync(absolute, { force: true });
}
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
+10 -36
View File
@@ -1,41 +1,15 @@
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import sanitizeMediaRowTextContent from "@/src/functions/backend/db/media/sanitize-media-row-text-content";
import type { AdminCrudAPIParams } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
const { table, body, user_types, user } = params;
/**
* A row written straight through the API still may not carry raw
* bytes or megabytes in `text_content`.
/**
* Media rows are never written straight through the CRUD API: a row
* names a file on disk, and only the upload routes (`upload-media`,
* `bots/upload-attachment`) write both the file and the row that points
* at it.
*/
let final_insert_data: BUN_SQLITE_COBBLE_MEDIA[] = [
...(body?.insert_data || []),
].map((d) => ({ ...sanitizeMediaRowTextContent({ row: d }), user_id: user.id }));
const is_user_allowed_to_post_any = checkUserAccess({
includes: ["admin"],
user_types,
});
if (!is_user_allowed_to_post_any.success) {
final_insert_data = final_insert_data.map((fid) => ({
...fid,
user_id: user.id,
}));
}
const POST = await BunSQLite.insert({
table,
data: final_insert_data,
update_on_duplicate: body?.update_on_duplicate,
});
return POST;
export default async function (_params: AdminCrudAPIParams): Promise<APIResponseObject> {
return {
success: false,
msg: `Upload files through the upload routes.`,
};
}
+34 -17
View File
@@ -5,11 +5,16 @@ import type { AdminCrudAPIParams } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
import { MediaUpdatableFields } from "./(data)/media-updatable-fields";
/**
* Updates one media row's description (`MediaUpdatableFields`), by id: the
* caller's own, or anyone's for an admin.
*/
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
const { table, body, user_types, id, query, user } = params;
const { table, body, user_types, id, user } = params;
if (!body?.update_data) {
return {
@@ -18,17 +23,37 @@ export default async function (
};
}
const is_user_allowed_to_put = checkUserAccess({
if (!id) {
return {
success: false,
msg: `No media id passed`,
};
}
const update_data = _.pick(body.update_data, MediaUpdatableFields);
if (!Object.keys(update_data)[0]) {
return {
success: false,
msg: `No updatable fields passed`,
};
}
const can_update_any = checkUserAccess({
includes: ["admin"],
user_types,
});
let final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {};
let targetId = id;
const existing = await BunSQLite.select<BUN_SQLITE_COBBLE_MEDIA, (typeof BunSQLiteTables)[number]>({
table,
query: { query: { id: { value: id } } },
}).then((res) => res.payload?.[0] || null);
if (!is_user_allowed_to_put.success) {
final_sql_query = {};
targetId = user.id;
if (!existing?.id || (!can_update_any.success && String(existing.user_id) !== String(user.id))) {
return {
success: false,
msg: `Media not found`,
};
}
/**
@@ -36,18 +61,10 @@ export default async function (
* an update that only sends `text_content` is judged on the file's
* real name and type.
*/
const existing = targetId
? await BunSQLite.select<BUN_SQLITE_COBBLE_MEDIA, (typeof BunSQLiteTables)[number]>({
table,
query: { query: { id: { value: targetId } } },
}).then((res) => res.payload?.[0] || null)
: null;
const PUT = await BunSQLite.update({
table,
data: sanitizeMediaRowTextContent({ row: body.update_data, existing }),
targetId,
query: final_sql_query,
data: sanitizeMediaRowTextContent({ row: update_data, existing }),
targetId: existing.id,
});
return PUT;
@@ -0,0 +1,43 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import grabUserTypes from "@/src/functions/backend/db/users/grab-user-types";
import type { UserType } from "@/src/types";
import userTypeAssignment from "@/src/utils/rbac/user-type-assignment";
type Params = {
rows: Partial<BUN_SQLITE_COBBLE_USER_TYPES>[];
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
action: "assign" | "delete";
};
/**
* The `user_types` rows the actor may grant or remove, judged like
* `saveUser` does: against the assignment matrix and the target user's
* current types (`userTypeAssignment`), so an admin can't mark another
* admin or the super admin read-only through the CRUD API. A row with no
* user or no type is left out.
*/
export default async function filterAssignableRows({ rows, actor_user_types, action }: Params) {
const current = new Map<string, UserType[]>();
for (const row of rows) {
const key = String(row.user_id ?? "");
if (!key || current.has(key)) continue;
const types = await grabUserTypes({ user_id: key });
current.set(key, types.map((type) => type.user_type as UserType).filter(Boolean));
}
return rows.filter(
(row) =>
row.user_id &&
row.user_type &&
userTypeAssignment({
actor_user_types,
target_user_type: row.user_type as UserType,
target_user_types: current.get(String(row.user_id)) || [],
action,
}),
);
}
@@ -9,6 +9,7 @@ import type { AdminCrudAPIParams, UserType } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
import filterAssignableRows from "./(functions)/filter-assignable-rows";
export default async function (
params: AdminCrudAPIParams,
@@ -58,9 +59,13 @@ export default async function (
ignored_user_types.push(...SuperAdminOnlyEditableUserTypes);
}
const user_types_to_delete = records_to_delete.payload.filter(
const user_types_to_delete = await filterAssignableRows({
rows: records_to_delete.payload.filter(
(rec) => !ignored_user_types.includes(rec.user_type as any),
);
),
actor_user_types: user_types,
action: "delete",
});
for (let i = 0; i < user_types_to_delete.length; i++) {
const user_type_to_delete = user_types_to_delete[i];
@@ -8,6 +8,7 @@ import type { AdminCrudAPIParams, UserType } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
import filterAssignableRows from "./(functions)/filter-assignable-rows";
export default async function (
params: AdminCrudAPIParams,
@@ -51,6 +52,19 @@ export default async function (
);
}
final_insert_data = await filterAssignableRows({
rows: final_insert_data,
actor_user_types: user_types,
action: "assign",
});
if (!final_insert_data[0]) {
return {
success: false,
msg: `You can't grant that user type.`,
};
}
const POST = await BunSQLite.insert({
table,
data: final_insert_data,
+8 -11
View File
@@ -34,18 +34,15 @@ export default async function (
includes: ["admin"],
});
if (!can_user_see_all_users.success) {
final_sql_query.query = {
...final_sql_query.query,
user_id: {
value: user.id,
},
};
}
/**
* `grabUsers` reads `user_id`, not `sql_query`, so anyone who can't
* list users is pinned to their own record there.
*/
const GET = await grabUsers({
query: final_query,
user_id: id,
query: can_user_see_all_users.success
? final_query
: { ...final_query, user_id: user.id },
user_id: can_user_see_all_users.success ? id : user.id,
});
return GET;
+1 -1
View File
@@ -56,7 +56,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
};
}
if (isReadOnlyUser({ user_types }) && !req.method.match(/get/)) {
if (isReadOnlyUser({ user_types }) && req.method != "GET") {
const is_self_user_profile_update =
req.method == "PUT" &&
table == "users" &&
+5
View File
@@ -7,6 +7,7 @@ import type {
} from "@moduletrace/bunext/types";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
@@ -31,6 +32,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
}
try {
if (isReadOnlyUser({ user_types })) {
throw new Error(`This user can only read records.`);
}
const { id, ids, media_paradigm, user_id } = body;
const can_delete_all_media = checkUserAccess({
+12
View File
@@ -5,6 +5,7 @@ import type {
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import _ from "lodash";
import canListUsers from "@/src/utils/rbac/can-list-users";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
@@ -27,6 +28,17 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
};
}
/**
* Anyone who can't manage users (members, read only) only ever
* gets their own record.
*/
if (!canListUsers({ user_types })) {
return await grabUsers({
query: { ...query, user_id: user.id },
user_id: user.id,
});
}
return await grabUsers({
query,
});
+2 -1
View File
@@ -2,6 +2,7 @@ import { statSync } from "node:fs";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabStorageFile from "@/src/functions/backend/storage/grab-storage-file";
import resolveStorageSpec from "@/src/functions/backend/storage/resolve-storage-spec";
import servedFileHeaders from "@/src/utils/served-file-headers";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { StorageFileRequestSchema } from "@/src/schemas/storage-schema";
@@ -47,7 +48,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, q
const headers = new Headers({
"Content-Type": file.mime_type,
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,
"X-Content-Type-Options": "nosniff",
...servedFileHeaders({ content_type: file.mime_type }),
ETag: etag,
"Cache-Control": "private, no-cache",
});
+21 -12
View File
@@ -3,7 +3,7 @@ import verifyUserPassword from "@/src/functions/backend/auth/verify-user-passwor
import loginUser from "@/src/functions/backend/auth/login-user";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import rateLimit from "@/src/utils/rate-limit";
import rateLimit, { resetRateLimit } from "@/src/utils/rate-limit";
import { PasswordLoginRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -31,19 +31,26 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
const window_ms = AppData["RateLimitWindowMinutes"] * 60 * 1000;
const ip_limit = rateLimit({
key: `password-login:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["PasswordAttemptLimit"] * 4,
window_ms,
});
const ip = grabRequestIP(req) || "unknown";
const id_key = identifier.toLowerCase();
const identifier_limit = rateLimit({
key: `password-login:id:${identifier.toLowerCase()}`,
limit: AppData["PasswordAttemptLimit"],
window_ms,
});
/**
* Three buckets: one per address, one per account from that
* address (the tight one), and one per account from anywhere,
* looser, so guessing from many addresses is still bounded while
* a stranger can't lock the owner out with a handful of tries.
*/
const limits = [
{ key: `password-login:ip:${ip}`, limit: AppData["PasswordAttemptLimit"] * 4 },
{ key: `password-login:id-ip:${id_key}:${ip}`, limit: AppData["PasswordAttemptLimit"] },
{ key: `password-login:id:${id_key}`, limit: AppData["PasswordAttemptLimit"] * 10 },
];
if (!ip_limit.allowed || !identifier_limit.allowed) {
const allowed = limits
.map(({ key, limit }) => rateLimit({ key, limit, window_ms }).allowed)
.every(Boolean);
if (!allowed) {
throw new AppError(`Too many attempts. Please try again later.`);
}
@@ -71,6 +78,8 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
};
}
resetRateLimit({ key: `password-login:id-ip:${id_key}:${ip}` });
return await loginUser({
user_id: target_user.id,
req,
+8
View File
@@ -18,6 +18,8 @@ import compressResponse from "./functions/server/compress-response";
import { ComputerProxyPrefix } from "./dict/computer-dict";
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
import { ensureVapidKeys } from "./push/vapid";
import isSameOriginUpgrade from "./utils/is-same-origin-upgrade";
import { setRequestPeerIP } from "./utils/request-peer-ip";
declare global {}
@@ -74,6 +76,8 @@ try {
*/
const server = Bun.serve({
async fetch(req, server) {
setRequestPeerIP({ req, ip: server.requestIP(req)?.address });
try {
const url = new URL(req.url);
@@ -102,6 +106,10 @@ const server = Bun.serve({
}
if (url.pathname === WEBSOCKET_ENDPOINT) {
if (!isSameOriginUpgrade({ req })) {
return new Response("Forbidden", { status: 403 });
}
const session = await userAuth({ req });
if (!session.user?.id) {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import clientIP, { isProxyAddress } from "./client-ip";
describe("isProxyAddress", () => {
test("loopback and private networks", () => {
for (const ip of ["127.0.0.1", "::1", "::ffff:127.0.0.1", "10.8.0.1", "192.168.1.4", "172.18.0.2", "fd00::1"]) {
expect(isProxyAddress({ ip })).toBe(true);
}
});
test("public addresses", () => {
for (const ip of ["8.8.8.8", "172.32.0.1", "2001:db8::1"]) {
expect(isProxyAddress({ ip })).toBe(false);
}
});
});
describe("clientIP", () => {
test("a direct client is its connection's address, whatever it claims", () => {
expect(clientIP({ peer: "203.0.113.9", forwarded_for: "1.2.3.4" })).toBe("203.0.113.9");
});
test("behind a proxy, the entry that proxy added", () => {
expect(clientIP({ peer: "127.0.0.1", forwarded_for: "1.2.3.4, 203.0.113.9" })).toBe("203.0.113.9");
});
test("behind a proxy that sends X-Real-IP only", () => {
expect(clientIP({ peer: "172.18.0.1", real_ip: "203.0.113.9" })).toBe("203.0.113.9");
});
test("a LAN client with no proxy headers is its own address", () => {
expect(clientIP({ peer: "192.168.1.20" })).toBe("192.168.1.20");
});
test("nothing known", () => {
expect(clientIP({})).toBeUndefined();
});
});
+59
View File
@@ -0,0 +1,59 @@
type Params = {
/**
* The connection's own address (`server.requestIP`).
*/
peer?: string | null;
/**
* The request's `X-Forwarded-For`.
*/
forwarded_for?: string | null;
/**
* The request's `X-Real-IP`.
*/
real_ip?: string | null;
};
/**
* Whether an address is this machine or a private network: where a reverse
* proxy or tunnel in front of the app connects from.
*/
export function isProxyAddress({ ip }: { ip: string }) {
const address = ip.replace(/^::ffff:/i, "").toLowerCase();
return (
address == "::1" ||
address.startsWith("127.") ||
address.startsWith("10.") ||
address.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(address) ||
address.startsWith("fc") ||
address.startsWith("fd") ||
address.startsWith("fe80:")
);
}
/**
* The client's address, for rate limits and the audit log. The forwarded
* headers are only believed when the connection came from a proxy
* (`isProxyAddress`), and then only the last `X-Forwarded-For` entry,
* the one that proxy added itself: anything before it is whatever the
* client chose to send. Otherwise the connection's own address wins, so a
* client can't pick a fresh address per request to dodge a limit.
*/
export default function clientIP({ peer, forwarded_for, real_ip }: Params) {
const behind_proxy = !peer || isProxyAddress({ ip: peer });
if (behind_proxy) {
const forwarded = (forwarded_for || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.at(-1);
if (forwarded) return forwarded;
if (real_ip?.trim()) return real_ip.trim();
}
return peer || undefined;
}
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import computerProxyResponseHeaders, { computerProxyCookies } from "./computer-proxy-response-headers";
const prefix = "/computers/3";
describe("computerProxyCookies", () => {
test("keeps a sandbox cookie to the computer's prefix", () => {
expect(
computerProxyCookies({ cookies: ["key=abc; Path=/; HttpOnly; Domain=example.com"], prefix }),
).toEqual(["key=abc; HttpOnly; Path=/computers/3/"]);
});
test("drops a cookie named like the app's session", () => {
expect(
computerProxyCookies({
cookies: [`${AppData.AuthKeyCookieName}=forged; Path=/`, `${AppData.AuthCSRFCookieName}=x`, "ok=1"],
prefix,
}),
).toEqual(["ok=1; Path=/computers/3/"]);
});
});
describe("computerProxyResponseHeaders", () => {
test("rewrites every Set-Cookie and keeps framing open", () => {
const headers = new Headers();
headers.append("set-cookie", "a=1; Path=/");
headers.append("set-cookie", `${AppData.AuthKeyCookieName}=forged; Path=/`);
headers.set("x-frame-options", "DENY");
const out = computerProxyResponseHeaders({ headers, base_url: "http://computer:8080", prefix });
expect(out.getSetCookie()).toEqual(["a=1; Path=/computers/3/"]);
expect(out.get("x-frame-options")).toBeNull();
});
test("points a redirect back under the prefix", () => {
const headers = new Headers({ location: "http://computer:8080/vnc/index.html" });
const out = computerProxyResponseHeaders({ headers, base_url: "http://computer:8080", prefix });
expect(out.get("location")).toBe("/computers/3/vnc/index.html");
});
});
@@ -1,3 +1,5 @@
import { AppData } from "@/src/data/app-data";
type Params = {
headers: Headers;
/**
@@ -25,6 +27,18 @@ export default function computerProxyResponseHeaders({ headers, base_url, prefix
for (const name of Dropped) out.delete(name);
/**
* The sandbox is served on the app's own origin, so a cookie it sets
* would land beside the app's session: each one is kept to the
* computer's own prefix, and one named like the app's session cookies
* is dropped.
*/
out.delete("set-cookie");
for (const cookie of computerProxyCookies({ cookies: headers.getSetCookie(), prefix })) {
out.append("set-cookie", cookie);
}
const csp = out.get("content-security-policy");
if (csp?.includes("frame-ancestors")) {
@@ -51,3 +65,23 @@ export default function computerProxyResponseHeaders({ headers, base_url, prefix
return out;
}
/**
* A computer's `Set-Cookie` values, each limited to `Path=<prefix>` (any
* `Path` or `Domain` it named removed), without the app's own cookie
* names.
*/
export function computerProxyCookies({ cookies, prefix }: { cookies: string[]; prefix: string }) {
const reserved: string[] = [AppData.AuthKeyCookieName, AppData.AuthCSRFCookieName, AppData.BuildCookieName];
return cookies
.filter((cookie) => !reserved.includes(cookie.split("=")[0]!.trim()))
.map((cookie) => {
const [pair, ...attributes] = cookie.split(";");
const kept = attributes
.map((attribute) => attribute.trim())
.filter((attribute) => attribute && !/^(path|domain)\s*=/i.test(attribute));
return [pair!.trim(), ...kept, `Path=${prefix}/`].join("; ");
});
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import isSameOriginUpgrade from "./is-same-origin-upgrade";
function upgrade({ headers }: { headers: Record<string, string> }) {
return new Request("http://localhost:7227/ws", { headers });
}
describe("isSameOriginUpgrade", () => {
test("the app's own page is let through", () => {
expect(
isSameOriginUpgrade({
req: upgrade({ headers: { origin: "http://10.8.0.1:7227", host: "10.8.0.1:7227" } }),
}),
).toBe(true);
});
test("behind a proxy that forwards the host", () => {
expect(
isSameOriginUpgrade({
req: upgrade({
headers: {
origin: "https://cobble.example.com",
host: "127.0.0.1:7227",
"x-forwarded-host": "cobble.example.com",
},
}),
}),
).toBe(true);
});
test("another port on the same machine is refused", () => {
expect(
isSameOriginUpgrade({
req: upgrade({ headers: { origin: "http://localhost:8080", host: "localhost:7227" } }),
}),
).toBe(false);
});
test("another site is refused", () => {
expect(
isSameOriginUpgrade({
req: upgrade({ headers: { origin: "https://evil.example", host: "cobble.example.com" } }),
}),
).toBe(false);
});
test("a malformed or opaque origin is refused", () => {
expect(isSameOriginUpgrade({ req: upgrade({ headers: { origin: "null", host: "localhost:7227" } }) })).toBe(false);
});
test("a client without an origin is left to the sign-in check", () => {
expect(isSameOriginUpgrade({ req: upgrade({ headers: { host: "localhost:7227" } }) })).toBe(true);
});
});
+38
View File
@@ -0,0 +1,38 @@
type Params = {
req: Request;
};
/**
* Whether a websocket upgrade came from a page of the app itself. A
* websocket handshake isn't bound by CORS, and the session cookies are
* `SameSite=Lax`, so a page on the same site but another origin (another
* port on the same machine, a sandbox's exposed port) would otherwise
* reach `/ws` or a computer's terminal with the user's session.
*
* The browser's `Origin` must name the host the request was made to: the
* `Host` header, or the proxy's `X-Forwarded-Host` when a proxy rewrote
* it. A client that sends no `Origin` isn't a browser page and carries no
* ambient cookies of its own, so it is let through to the usual sign-in
* check.
*/
export default function isSameOriginUpgrade({ req }: Params) {
const origin = req.headers.get("origin");
if (!origin) return true;
let origin_host = "";
try {
origin_host = new URL(origin).host.toLowerCase();
} catch {
return false;
}
if (!origin_host) return false;
const hosts = [req.headers.get("host"), ...(req.headers.get("x-forwarded-host") || "").split(",")]
.map((host) => (host || "").trim().toLowerCase())
.filter(Boolean);
return hosts.includes(origin_host);
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import mediaFilePath from "./media-file-path";
const data_dir = "/data";
const db_dir = "/data/db";
describe("mediaFilePath", () => {
test("resolves a path inside the data directory", () => {
expect(mediaFilePath({ write_path: "media/3/a.png", data_dir, db_dir })).toBe("/data/media/3/a.png");
expect(mediaFilePath({ write_path: "bots/2/report.md", data_dir, db_dir })).toBe("/data/bots/2/report.md");
});
test("refuses a path that climbs out of the data directory", () => {
expect(mediaFilePath({ write_path: "../app/.env", data_dir, db_dir })).toBeNull();
expect(mediaFilePath({ write_path: "media/../../etc/passwd", data_dir, db_dir })).toBeNull();
});
test("refuses an absolute path", () => {
expect(mediaFilePath({ write_path: "/app/.env", data_dir, db_dir })).toBeNull();
});
test("refuses the database folder", () => {
expect(mediaFilePath({ write_path: "db/cobble.db", data_dir, db_dir })).toBeNull();
expect(mediaFilePath({ write_path: "media/../db/cobble.db", data_dir, db_dir })).toBeNull();
});
test("refuses empty paths, the root itself and NUL bytes", () => {
expect(mediaFilePath({ write_path: "", data_dir, db_dir })).toBeNull();
expect(mediaFilePath({ write_path: ".", data_dir, db_dir })).toBeNull();
expect(mediaFilePath({ write_path: "media/a\0.png", data_dir, db_dir })).toBeNull();
});
});
+41
View File
@@ -0,0 +1,41 @@
import path from "node:path";
import grabDirNames from "./grab-dir-names";
import { isInsideRoot } from "./resolve-bot-path";
type Params = {
/**
* A `media` row's `media_write_path` or `media_thumbnail_write_path`,
* relative to the data directory (e.g. `media/3/abc.png`).
*/
write_path?: string | null;
/**
* The data directory, else `DATA_DIR` (tests pass their own).
*/
data_dir?: string;
/**
* The database directory, else `DB_DIR` (tests pass their own).
*/
db_dir?: string;
};
/**
* The absolute path of a media row's file, or null when the row points
* anywhere it must not: outside the data directory (`../app/.env`, an
* absolute path) or into the database's own folder. Every read or delete
* of a media file goes through this, so a row's path can never become a
* way onto the rest of the disk.
*/
export default function mediaFilePath({ write_path, data_dir, db_dir }: Params): string | null {
if (!write_path || write_path.includes("\0")) return null;
const root = path.resolve(data_dir || grabDirNames().DATA_DIR);
const absolute = path.resolve(root, write_path);
if (absolute == root || !isInsideRoot({ root, absolute })) return null;
const database = path.resolve(db_dir || process.env.DB_DIR || path.join(root, "db"));
if (isInsideRoot({ root: database, absolute })) return null;
return absolute;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* The address each request's connection came from, as `Bun.serve` saw it
* (`server.requestIP`). Recorded by `src/server.ts` before the request is
* handed on, since API routes only get the `Request`.
*/
const peers = new WeakMap<Request, string>();
export function setRequestPeerIP({ req, ip }: { req: Request; ip?: string | null }) {
if (ip) peers.set(req, ip);
}
export function grabRequestPeerIP({ req }: { req: Request }) {
return peers.get(req);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import servedFileHeaders, { isActiveContentType } from "./served-file-headers";
describe("isActiveContentType", () => {
test("pages, SVG, XML and scripts are active", () => {
for (const content_type of [
"text/html",
"text/html; charset=utf-8",
"TEXT/HTML",
"image/svg+xml",
"application/xhtml+xml",
"application/xml",
"application/rss+xml",
"text/javascript",
]) {
expect(isActiveContentType({ content_type })).toBe(true);
}
});
test("images, PDFs, audio and plain text are not", () => {
for (const content_type of ["image/png", "application/pdf", "audio/mpeg", "text/plain", "text/markdown", "", null]) {
expect(isActiveContentType({ content_type })).toBe(false);
}
});
});
describe("servedFileHeaders", () => {
test("every file is served without sniffing", () => {
expect(servedFileHeaders({ content_type: "application/pdf" })).toEqual({
"X-Content-Type-Options": "nosniff",
});
});
test("an active file is sandboxed", () => {
expect(servedFileHeaders({ content_type: "text/html" })).toEqual({
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox",
});
});
});
+52
View File
@@ -0,0 +1,52 @@
type Params = {
/**
* The type the file goes out with, e.g. `text/html; charset=utf-8`.
*/
content_type?: string | null;
};
/**
* Types a browser runs as a page or script when opened. Files of these
* types come from users and bots (a report a bot wrote from a scraped
* site), and they are served from the app's own origin, so opened as they
* are they would run with the viewer's session.
*/
const ActiveContentTypes = [
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"text/xml",
"application/xml",
"text/javascript",
"application/javascript",
"application/ecmascript",
"text/ecmascript",
"text/xsl",
];
/**
* Whether a type is one a browser would run (`ActiveContentTypes`), any
* `+xml` type included.
*/
export function isActiveContentType({ content_type }: Params) {
const type = (content_type || "").split(";")[0]!.trim().toLowerCase();
return ActiveContentTypes.includes(type) || type.endsWith("+xml");
}
/**
* The headers every user or bot file is served with: never sniffed into
* another type, and an active one (HTML, SVG, XML, scripts) sandboxed, so
* it still opens but runs no script and gets an origin of its own instead
* of the app's. Other types (images, PDFs, audio) are left alone, since a
* sandbox stops the browser's PDF viewer.
*/
export default function servedFileHeaders({ content_type }: Params): Record<string, string> {
const headers: Record<string, string> = { "X-Content-Type-Options": "nosniff" };
if (isActiveContentType({ content_type })) {
headers["Content-Security-Policy"] = "sandbox";
}
return headers;
}
+43
View File
@@ -0,0 +1,43 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import walkRegularFiles from "./walk-regular-files";
let root = "";
let outside = "";
beforeAll(() => {
root = mkdtempSync(path.join(tmpdir(), "cobble-walk-"));
outside = mkdtempSync(path.join(tmpdir(), "cobble-walk-outside-"));
writeFileSync(path.join(outside, "secret.env"), "ENCRYPTION_KEY=x");
mkdirSync(path.join(outside, "folder"));
writeFileSync(path.join(outside, "folder", "db.sqlite"), "db");
mkdirSync(path.join(root, "reports"));
writeFileSync(path.join(root, "a.md"), "a");
writeFileSync(path.join(root, "reports", "b.md"), "b");
symlinkSync(path.join(outside, "secret.env"), path.join(root, "secret.env"));
symlinkSync(path.join(outside, "folder"), path.join(root, "linked"));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
describe("walkRegularFiles", () => {
test("lists regular files in nested folders", () => {
const found = [...walkRegularFiles({ dir: root })].map((p) => path.relative(root, p)).sort();
expect(found).toEqual(["a.md", "reports/b.md"]);
});
test("never follows a symlink to a file or a folder", () => {
const found = [...walkRegularFiles({ dir: root })];
expect(found.some((p) => p.includes("secret.env"))).toBe(false);
expect(found.some((p) => p.includes("db.sqlite"))).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { lstatSync, readdirSync } from "node:fs";
import path from "node:path";
type Params = {
dir: string;
};
/**
* Every regular file under a directory, depth first. Links are never
* followed: an archive unpacked from a computer can hold a symlink
* (`x -> /app/.env`), which would otherwise be read from the app's own
* disk once it lands here.
*/
export default function* walkRegularFiles({ dir }: Params): Generator<string> {
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
const stats = lstatSync(full);
if (stats.isDirectory()) yield* walkRegularFiles({ dir: full });
else if (stats.isFile()) yield full;
}
}