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
@@ -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>({
+11 -37
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`.
*/
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<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(
(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];
@@ -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",
});