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:
@@ -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