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