diff --git a/README.md b/README.md index 57725ad..10f2dc1 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,10 @@ point that at `http://: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. diff --git a/src/bots-queue/load-attachment-parts.ts b/src/bots-queue/load-attachment-parts.ts index e7e26a5..095d6bd 100644 --- a/src/bots-queue/load-attachment-parts.ts +++ b/src/bots-queue/load-attachment-parts.ts @@ -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 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] }); diff --git a/src/functions/backend/db/bot-files/store-chat-attachments.ts b/src/functions/backend/db/bot-files/store-chat-attachments.ts index e2c29b6..1d5cb3f 100644 --- a/src/functions/backend/db/bot-files/store-chat-attachments.ts +++ b/src/functions/backend/db/bot-files/store-chat-attachments.ts @@ -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; diff --git a/src/functions/backend/db/media/delete-media.ts b/src/functions/backend/db/media/delete-media.ts index 9b6b85f..20d423f 100644 --- a/src/functions/backend/db/media/delete-media.ts +++ b/src/functions/backend/db/media/delete-media.ts @@ -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(); @@ -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({ diff --git a/src/functions/backend/db/profile-images/copy-media-as-picture.ts b/src/functions/backend/db/profile-images/copy-media-as-picture.ts index 7e3b6fa..ddbd0cd 100644 --- a/src/functions/backend/db/profile-images/copy-media-as-picture.ts +++ b/src/functions/backend/db/profile-images/copy-media-as-picture.ts @@ -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.`); diff --git a/src/functions/backend/db/users/grab-users.ts b/src/functions/backend/db/users/grab-users.ts index 27deb48..5e13acf 100644 --- a/src/functions/backend/db/users/grab-users.ts +++ b/src/functions/backend/db/users/grab-users.ts @@ -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`; - } - values.push(isId ? term_value : match_value_param); + 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, "%")}%`, + ); } - 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`; } diff --git a/src/functions/backend/db/users/media/upload-and-record-media.ts b/src/functions/backend/db/users/media/upload-and-record-media.ts index 376e5f8..240d763 100644 --- a/src/functions/backend/db/users/media/upload-and-record-media.ts +++ b/src/functions/backend/db/users/media/upload-and-record-media.ts @@ -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> { 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 diff --git a/src/functions/backend/logger.ts b/src/functions/backend/logger.ts index 15a03ec..b369996 100644 --- a/src/functions/backend/logger.ts +++ b/src/functions/backend/logger.ts @@ -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 = { diff --git a/src/functions/server/computers/handle-computer-proxy.ts b/src/functions/server/computers/handle-computer-proxy.ts index 3d97584..2a777f3 100644 --- a/src/functions/server/computers/handle-computer-proxy.ts +++ b/src/functions/server/computers/handle-computer-proxy.ts @@ -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()) diff --git a/src/functions/server/media/handle-media-server.ts b/src/functions/server/media/handle-media-server.ts index 5b72b7b..15502a4 100644 --- a/src/functions/server/media/handle-media-server.ts +++ b/src/functions/server/media/handle-media-server.ts @@ -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 - ? media.media_thumbnail_write_path - : media.media_write_path; + const full_media_location = mediaFilePath({ + write_path: thumbnail + ? media.media_thumbnail_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 diff --git a/src/harness/tools/computer/import-file-tool.ts b/src/harness/tools/computer/import-file-tool.ts index 13263cb..eec4992 100644 --- a/src/harness/tools/computer/import-file-tool.ts +++ b/src/harness/tools/computer/import-file-tool.ts @@ -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 { - 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. diff --git a/src/harness/tools/storage/read-file-tool.ts b/src/harness/tools/storage/read-file-tool.ts index 780f07d..1d590f0 100644 --- a/src/harness/tools/storage/read-file-tool.ts +++ b/src/harness/tools/storage/read-file-tool.ts @@ -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: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, diff --git a/src/pages/api/admin/crud/(tables)/media/(data)/media-updatable-fields.ts b/src/pages/api/admin/crud/(tables)/media/(data)/media-updatable-fields.ts new file mode 100644 index 0000000..5b4dd0a --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/media/(data)/media-updatable-fields.ts @@ -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; diff --git a/src/pages/api/admin/crud/(tables)/media/del.ts b/src/pages/api/admin/crud/(tables)/media/del.ts index 5387678..6219909 100644 --- a/src/pages/api/admin/crud/(tables)/media/del.ts +++ b/src/pages/api/admin/crud/(tables)/media/del.ts @@ -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, ServerQueryParam @@ -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({ diff --git a/src/pages/api/admin/crud/(tables)/media/post.ts b/src/pages/api/admin/crud/(tables)/media/post.ts index 3f56bc0..b90e3a4 100644 --- a/src/pages/api/admin/crud/(tables)/media/post.ts +++ b/src/pages/api/admin/crud/(tables)/media/post.ts @@ -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 { - 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`. - */ - 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; +/** + * 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. + */ +export default async function (_params: AdminCrudAPIParams): Promise { + return { + success: false, + msg: `Upload files through the upload routes.`, + }; } diff --git a/src/pages/api/admin/crud/(tables)/media/put.ts b/src/pages/api/admin/crud/(tables)/media/put.ts index 16e8615..cffc942 100644 --- a/src/pages/api/admin/crud/(tables)/media/put.ts +++ b/src/pages/api/admin/crud/(tables)/media/put.ts @@ -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 { - 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({ + 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({ - 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; diff --git a/src/pages/api/admin/crud/(tables)/user_types/(functions)/filter-assignable-rows.ts b/src/pages/api/admin/crud/(tables)/user_types/(functions)/filter-assignable-rows.ts new file mode 100644 index 0000000..f752bd5 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/user_types/(functions)/filter-assignable-rows.ts @@ -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[]; + 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(); + + 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, + }), + ); +} diff --git a/src/pages/api/admin/crud/(tables)/user_types/del.ts b/src/pages/api/admin/crud/(tables)/user_types/del.ts index 6056560..f0acc3b 100644 --- a/src/pages/api/admin/crud/(tables)/user_types/del.ts +++ b/src/pages/api/admin/crud/(tables)/user_types/del.ts @@ -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( - (rec) => !ignored_user_types.includes(rec.user_type as any), - ); + 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]; diff --git a/src/pages/api/admin/crud/(tables)/user_types/post.ts b/src/pages/api/admin/crud/(tables)/user_types/post.ts index 9a51cf2..a8dbe98 100644 --- a/src/pages/api/admin/crud/(tables)/user_types/post.ts +++ b/src/pages/api/admin/crud/(tables)/user_types/post.ts @@ -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, diff --git a/src/pages/api/admin/crud/(tables)/users/get.ts b/src/pages/api/admin/crud/(tables)/users/get.ts index 2ae27f0..7f3cb7b 100644 --- a/src/pages/api/admin/crud/(tables)/users/get.ts +++ b/src/pages/api/admin/crud/(tables)/users/get.ts @@ -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; diff --git a/src/pages/api/admin/crud/[[...paths]].ts b/src/pages/api/admin/crud/[[...paths]].ts index a359ced..bdc0fef 100644 --- a/src/pages/api/admin/crud/[[...paths]].ts +++ b/src/pages/api/admin/crud/[[...paths]].ts @@ -56,7 +56,7 @@ export const handler: BunextAPIRouteHandler = 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" && diff --git a/src/pages/api/admin/delete-media.ts b/src/pages/api/admin/delete-media.ts index 3113ddb..288efc0 100644 --- a/src/pages/api/admin/delete-media.ts +++ b/src/pages/api/admin/delete-media.ts @@ -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 = async ( params, @@ -31,6 +32,10 @@ export const handler: BunextAPIRouteHandler = 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({ diff --git a/src/pages/api/admin/grab-users.ts b/src/pages/api/admin/grab-users.ts index 7598e07..1e7b8ae 100644 --- a/src/pages/api/admin/grab-users.ts +++ b/src/pages/api/admin/grab-users.ts @@ -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 = async ({ req, @@ -27,6 +28,17 @@ export const handler: BunextAPIRouteHandler = 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, }); diff --git a/src/pages/api/admin/storage/file.ts b/src/pages/api/admin/storage/file.ts index 7a2bfb4..1a618a9 100644 --- a/src/pages/api/admin/storage/file.ts +++ b/src/pages/api/admin/storage/file.ts @@ -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 = 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", }); diff --git a/src/pages/api/auth/password-login.ts b/src/pages/api/auth/password-login.ts index 4865063..c0b4b95 100644 --- a/src/pages/api/auth/password-login.ts +++ b/src/pages/api/auth/password-login.ts @@ -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 = 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 = async ({ }; } + resetRateLimit({ key: `password-login:id-ip:${id_key}:${ip}` }); + return await loginUser({ user_id: target_user.id, req, diff --git a/src/server.ts b/src/server.ts index c253b36..14531cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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) { diff --git a/src/utils/client-ip.test.ts b/src/utils/client-ip.test.ts new file mode 100644 index 0000000..b01bdcb --- /dev/null +++ b/src/utils/client-ip.test.ts @@ -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(); + }); +}); diff --git a/src/utils/client-ip.ts b/src/utils/client-ip.ts new file mode 100644 index 0000000..97ad3d9 --- /dev/null +++ b/src/utils/client-ip.ts @@ -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; +} diff --git a/src/utils/computer-proxy-response-headers.test.ts b/src/utils/computer-proxy-response-headers.test.ts new file mode 100644 index 0000000..7a42519 --- /dev/null +++ b/src/utils/computer-proxy-response-headers.test.ts @@ -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"); + }); +}); diff --git a/src/utils/computer-proxy-response-headers.ts b/src/utils/computer-proxy-response-headers.ts index b7d2d95..0f37503 100644 --- a/src/utils/computer-proxy-response-headers.ts +++ b/src/utils/computer-proxy-response-headers.ts @@ -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=` (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("; "); + }); +} diff --git a/src/utils/is-same-origin-upgrade.test.ts b/src/utils/is-same-origin-upgrade.test.ts new file mode 100644 index 0000000..fb4fd7d --- /dev/null +++ b/src/utils/is-same-origin-upgrade.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import isSameOriginUpgrade from "./is-same-origin-upgrade"; + +function upgrade({ headers }: { headers: Record }) { + 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); + }); +}); diff --git a/src/utils/is-same-origin-upgrade.ts b/src/utils/is-same-origin-upgrade.ts new file mode 100644 index 0000000..1fa9321 --- /dev/null +++ b/src/utils/is-same-origin-upgrade.ts @@ -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); +} diff --git a/src/utils/media-file-path.test.ts b/src/utils/media-file-path.test.ts new file mode 100644 index 0000000..53d2631 --- /dev/null +++ b/src/utils/media-file-path.test.ts @@ -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(); + }); +}); diff --git a/src/utils/media-file-path.ts b/src/utils/media-file-path.ts new file mode 100644 index 0000000..df41f8b --- /dev/null +++ b/src/utils/media-file-path.ts @@ -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; +} diff --git a/src/utils/request-peer-ip.ts b/src/utils/request-peer-ip.ts new file mode 100644 index 0000000..86e2428 --- /dev/null +++ b/src/utils/request-peer-ip.ts @@ -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(); + +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); +} diff --git a/src/utils/served-file-headers.test.ts b/src/utils/served-file-headers.test.ts new file mode 100644 index 0000000..8ed805c --- /dev/null +++ b/src/utils/served-file-headers.test.ts @@ -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", + }); + }); +}); diff --git a/src/utils/served-file-headers.ts b/src/utils/served-file-headers.ts new file mode 100644 index 0000000..c25a8f7 --- /dev/null +++ b/src/utils/served-file-headers.ts @@ -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 { + const headers: Record = { "X-Content-Type-Options": "nosniff" }; + + if (isActiveContentType({ content_type })) { + headers["Content-Security-Policy"] = "sandbox"; + } + + return headers; +} diff --git a/src/utils/walk-regular-files.test.ts b/src/utils/walk-regular-files.test.ts new file mode 100644 index 0000000..355fbf8 --- /dev/null +++ b/src/utils/walk-regular-files.test.ts @@ -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); + }); +}); diff --git a/src/utils/walk-regular-files.ts b/src/utils/walk-regular-files.ts new file mode 100644 index 0000000..dd7abd8 --- /dev/null +++ b/src/utils/walk-regular-files.ts @@ -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 { + 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; + } +}