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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user