fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreate((e) => {
e.record.set("isAdmin", false);
e.next();
}, "users");
-633
View File
@@ -1,633 +0,0 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
const NETWORK_SCOPES = ["public", "private", "loopback"];
const PERMISSIONS = ["view", "modify", "full"];
const PARAMETER_TYPES = ["string", "number", "boolean"];
const TOP_LEVEL_FIELDS = [
"$schema",
"name",
"description",
"iconUrl",
"sources",
"requests",
"optionsSchema",
"defaultOptions",
"template",
];
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
const REQUEST_FIELDS = [
"id",
"sourceId",
"kind",
"method",
"pathTemplate",
"parameters",
"queryTemplate",
"bodyTemplate",
"staticHeaders",
"auth",
"minimumBoardPermission",
"trigger",
"cacheTtlSeconds",
"confirmation",
"invalidates",
];
const OPTION_FIELDS = [
"type",
"title",
"description",
"default",
"enum",
"const",
"minimum",
"maximum",
"multipleOf",
"minLength",
"maxLength",
"minItems",
"maxItems",
"format",
"items",
"properties",
"required",
"additionalProperties",
"if",
"then",
"else",
"x-homarr",
];
const OPTION_TYPES = ["object", "array", "string", "number", "integer", "boolean", "null"];
const OPTION_CONTROLS = [
"text",
"textarea",
"number",
"switch",
"select",
"multi-select",
"slider",
"date",
"time",
"color",
"icon",
"url",
"duration",
"timeZone",
"json",
];
const OPTION_FORMATS = ["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"];
const RESERVED_HEADERS = [
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
];
const SENSITIVE_KEY =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/i;
const CREDENTIAL_TEXT =
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/i;
function bad(message) {
throw new BadRequestError(message);
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validId(value) {
return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value);
}
function validBindingId(value) {
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
}
function validHttpUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s]+$/i.test(value);
}
function protectedHeader(name) {
const normalized = name.trim().toLowerCase();
return (
RESERVED_HEADERS.includes(normalized) ||
normalized.startsWith("proxy-") ||
normalized.startsWith("sec-") ||
normalized.startsWith("x-forwarded-")
);
}
function validHeaderName(name) {
return (
typeof name === "string" &&
name.length <= 256 &&
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) &&
!protectedHeader(name)
);
}
function hasControlCharacter(value) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 31 || code === 127) return true;
}
return false;
}
function validateSource(source) {
if (
!isObject(source) ||
Object.keys(source).some((key) => !SOURCE_FIELDS.includes(key)) ||
!validId(source.id) ||
typeof source.name !== "string" ||
!source.name.trim() ||
source.name.length > 128 ||
typeof source.baseUrl !== "string" ||
!NETWORK_SCOPES.includes(source.networkScope) ||
!isObject(source.auth) ||
!AUTH_TYPES.includes(source.auth.type)
)
bad("Widget contains an invalid source");
if (!/^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?$/i.test(source.baseUrl)) bad("Widget source URL is invalid");
const authority = source.baseUrl.replace(/^https?:\/\//i, "").split("/")[0];
if (authority.includes("@")) bad("Widget source URLs must not contain credentials or query values");
const authFields =
source.auth.type === "apiKeyHeader"
? ["type", "headerName"]
: source.auth.type === "apiKeyQuery"
? ["type", "parameterName"]
: ["type"];
if (Object.keys(source.auth).some((key) => !authFields.includes(key))) bad("Widget source authentication is invalid");
if (source.auth.type === "apiKeyHeader" && !validHeaderName(source.auth.headerName))
bad("API key authentication requires a safe HTTP header name");
if (
source.auth.type === "apiKeyQuery" &&
(typeof source.auth.parameterName !== "string" ||
!source.auth.parameterName ||
source.auth.parameterName.length > 256 ||
hasControlCharacter(source.auth.parameterName))
)
bad("API key query authentication requires a safe parameterName");
}
function validateRequest(request, sourceIds) {
if (
!isObject(request) ||
Object.keys(request).some((key) => !REQUEST_FIELDS.includes(key)) ||
!validId(request.id) ||
!sourceIds.has(request.sourceId) ||
!["query", "action"].includes(request.kind) ||
!METHODS.includes(request.method) ||
typeof request.pathTemplate !== "string" ||
!request.pathTemplate.startsWith("/") ||
request.pathTemplate.startsWith("//") ||
request.pathTemplate.includes("\\") ||
request.pathTemplate.includes("#") ||
!isObject(request.parameters) ||
!["inherit", "none"].includes(request.auth) ||
!PERMISSIONS.includes(request.minimumBoardPermission)
)
bad("Widget contains an invalid request");
if (
Object.keys(request.parameters).length > 32 ||
Object.entries(request.parameters).some(([name, type]) => !validBindingId(name) || !PARAMETER_TYPES.includes(type))
)
bad("Widget request parameters are invalid");
const trigger = request.trigger || "manual";
if (!["load", "manual"].includes(trigger) || (request.kind === "action" && trigger === "load"))
bad("Actions cannot run automatically");
if (request.kind === "query" && request.confirmation !== undefined) bad("Only actions can require confirmation");
if (request.kind === "query" && request.invalidates !== undefined) bad("Only actions can invalidate queries");
if (request.method === "DELETE" && request.minimumBoardPermission !== "full")
bad("DELETE requests require full permission");
if (
request.queryTemplate !== undefined &&
(!isObject(request.queryTemplate) || Object.keys(request.queryTemplate).some((key) => !key || key.length > 256))
)
bad("Widget query template is invalid");
if (
request.confirmation !== undefined &&
(!isObject(request.confirmation) ||
Object.keys(request.confirmation).some(
(key) => !["title", "message", "confirmLabel", "destructive"].includes(key),
) ||
typeof request.confirmation.title !== "string" ||
!request.confirmation.title ||
request.confirmation.title.length > 128 ||
typeof request.confirmation.message !== "string" ||
!request.confirmation.message ||
request.confirmation.message.length > 512 ||
(request.confirmation.confirmLabel !== undefined &&
(typeof request.confirmation.confirmLabel !== "string" ||
!request.confirmation.confirmLabel ||
request.confirmation.confirmLabel.length > 64)) ||
(request.confirmation.destructive !== undefined && typeof request.confirmation.destructive !== "boolean"))
)
bad("Widget action confirmation is invalid");
if (
request.invalidates !== undefined &&
(!Array.isArray(request.invalidates) ||
request.invalidates.length > 32 ||
request.invalidates.some((id) => !validId(id)))
)
bad("Widget invalidation targets are invalid");
if (
request.cacheTtlSeconds !== undefined &&
(!Number.isInteger(request.cacheTtlSeconds) ||
request.cacheTtlSeconds < 0 ||
request.cacheTtlSeconds > 3600 ||
(request.kind === "action" && request.cacheTtlSeconds !== 0))
)
bad("Widget request cache duration is invalid");
if (request.staticHeaders !== undefined) {
if (!isObject(request.staticHeaders) || Object.keys(request.staticHeaders).length > 32)
bad("Widget static headers are invalid");
for (const [name, value] of Object.entries(request.staticHeaders)) {
if (!validHeaderName(name) || typeof value !== "string" || value.length > 8192)
bad("Widget static headers are invalid");
if ((SENSITIVE_KEY.test(name) && value.trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(value))
bad("Widget static headers must not contain credentials");
}
}
const used = new Set();
const placeholderPattern = /\{([^{}]+)\}/g;
let placeholder;
while ((placeholder = placeholderPattern.exec(request.pathTemplate)) !== null) used.add(placeholder[1]);
collectParams(request.queryTemplate, used);
collectParams(request.bodyTemplate, used);
for (const name of used)
if (!Object.prototype.hasOwnProperty.call(request.parameters, name))
bad("Widget request uses an undeclared parameter");
}
function collectParams(value, result) {
if (Array.isArray(value)) {
for (const item of value) collectParams(item, result);
return;
}
if (!isObject(value)) return;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0][0] === "$param" && typeof entries[0][1] === "string") {
result.add(entries[0][1]);
return;
}
for (const entry of entries) collectParams(entry[1], result);
}
function validateOptionsMetadata(metadata, schema) {
if (
!isObject(metadata) ||
Object.keys(metadata).some((key) => !["control", "placeholder", "advanced", "order", "optionsSource"].includes(key))
)
bad("Widget option presentation metadata is invalid");
if (metadata.control !== undefined && !OPTION_CONTROLS.includes(metadata.control))
bad("Widget option control is invalid");
const controlsByType = {
string: ["text", "textarea", "select", "date", "time", "color", "icon", "url", "timeZone", "json"],
number: ["number", "select", "slider", "duration", "json"],
integer: ["number", "select", "slider", "duration", "json"],
boolean: ["switch", "select", "json"],
array: ["multi-select", "json"],
object: ["json"],
null: ["json"],
};
if (
metadata.control !== undefined &&
schema.type !== undefined &&
!controlsByType[schema.type].includes(metadata.control)
)
bad("Widget option control does not match its type");
if (metadata.placeholder !== undefined && typeof metadata.placeholder !== "string")
bad("Widget option placeholder is invalid");
if (metadata.advanced !== undefined && typeof metadata.advanced !== "boolean")
bad("Widget advanced option metadata is invalid");
if (metadata.order !== undefined && (typeof metadata.order !== "number" || !Number.isFinite(metadata.order)))
bad("Widget option order is invalid");
if (metadata.optionsSource !== undefined) {
const source = metadata.optionsSource;
if (
!isObject(source) ||
Object.keys(source).some((key) => !["requestId", "itemsPath", "valuePath", "labelPath"].includes(key)) ||
!["requestId", "valuePath", "labelPath"].every((key) => typeof source[key] === "string" && source[key]) ||
(source.itemsPath !== undefined && (typeof source.itemsPath !== "string" || !source.itemsPath))
)
bad("Widget dynamic option source is invalid");
if (!["select", "multi-select"].includes(metadata.control)) bad("Widget dynamic options require a select control");
}
}
function validateOptionsSchema(schema, root, requireType) {
if (!isObject(schema) || Object.keys(schema).some((key) => !OPTION_FIELDS.includes(key)))
bad("Widget options schema is invalid");
if (requireType !== false && !OPTION_TYPES.includes(schema.type)) bad("Every widget option needs one supported type");
if (schema.type !== undefined && !OPTION_TYPES.includes(schema.type)) bad("Widget option type is invalid");
if (root && schema.type !== "object") bad("Widget options schema root must be an object");
if (schema.type === "object" && schema.additionalProperties !== false)
bad("Widget object options must reject unknown fields");
for (const key of ["title", "description"])
if (schema[key] !== undefined && typeof schema[key] !== "string") bad("Widget option labels are invalid");
for (const key of ["minimum", "maximum", "multipleOf"])
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
bad("Widget numeric option constraints are invalid");
if (schema.multipleOf !== undefined && schema.multipleOf <= 0) bad("Widget multipleOf must be positive");
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum)
bad("Widget option bounds are invalid");
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || schema[key] < 0))
bad("Widget option size constraints are invalid");
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 100))
bad("Widget option enum is invalid");
if (schema.format !== undefined && (!OPTION_FORMATS.includes(schema.format) || schema.type !== "string"))
bad("Widget option format is invalid");
if (Array.isArray(schema.enum) && schema.enum.some((entry) => !optionMatches({ type: schema.type }, entry)))
bad("Widget option enum values do not match their type");
if (schema.const !== undefined && !optionMatches({ type: schema.type }, schema.const))
bad("Widget option constant does not match its type");
if (schema.properties !== undefined) {
if (!isObject(schema.properties)) bad("Widget option properties are invalid");
for (const [name, child] of Object.entries(schema.properties)) {
if (!validBindingId(name))
bad("Widget option names must start with a letter and contain letters, numbers, - or _");
if (SENSITIVE_KEY.test(name)) bad("Credentials must use source authentication, not widget options");
validateOptionsSchema(child, false, true);
}
if (schema.type !== undefined && schema.type !== "object") bad("Widget option properties require an object type");
if (
schema.required !== undefined &&
(!Array.isArray(schema.required) ||
schema.required.some(
(key) => typeof key !== "string" || !Object.prototype.hasOwnProperty.call(schema.properties, key),
))
)
bad("Widget required options are invalid");
}
if (schema.required !== undefined && !Array.isArray(schema.required)) bad("Widget required options are invalid");
if (schema.type === "array" && schema.items === undefined) bad("Widget array options require items");
if (schema.items !== undefined) {
if (schema.type !== undefined && schema.type !== "array") bad("Widget option items require an array type");
validateOptionsSchema(schema.items, false, true);
}
for (const branch of ["if", "then", "else"])
if (schema[branch] !== undefined) validateOptionsSchema(schema[branch], false, branch !== "if");
if (schema["x-homarr"] !== undefined) validateOptionsMetadata(schema["x-homarr"], schema);
}
function optionMatches(schema, value) {
if (schema.const !== undefined && value !== schema.const) return false;
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false;
if (schema.type === "object" && !isObject(value)) return false;
if (schema.type === "array" && !Array.isArray(value)) return false;
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value))) return false;
if (schema.type === "number" && typeof value !== "number") return false;
if (schema.type === "string" && typeof value !== "string") return false;
if (schema.type === "boolean" && typeof value !== "boolean") return false;
if (schema.type === "null" && value !== null) return false;
if (isObject(schema.properties) && isObject(value)) {
for (const [key, child] of Object.entries(schema.properties)) {
if (value[key] !== undefined && isObject(child) && !optionMatches(child, value[key])) return false;
}
}
return true;
}
function validateOptionsValue(schema, value) {
if (!optionMatches(schema, value)) bad("Widget default options do not match their schema");
if (schema.type === "object") {
if (!isObject(value)) bad("Widget default options do not match their schema");
const branch =
isObject(schema.if) && optionMatches(schema.if, value)
? schema.then
: isObject(schema.if)
? schema.else
: undefined;
const properties = Object.assign(
{},
isObject(schema.properties) ? schema.properties : {},
isObject(branch) && isObject(branch.properties) ? branch.properties : {},
);
const required = (Array.isArray(schema.required) ? schema.required : []).concat(
isObject(branch) && Array.isArray(branch.required) ? branch.required : [],
);
for (const key of required) if (value[key] === undefined) bad("Widget default options omit a required value");
for (const [key, child] of Object.entries(value)) {
if (!isObject(properties[key])) bad("Widget default options contain an unknown value");
validateOptionsValue(properties[key], child);
}
return;
}
if (schema.type === "array") {
if (!Array.isArray(value)) bad("Widget default options do not match their schema");
if (isObject(schema.items)) for (const item of value) validateOptionsValue(schema.items, item);
return;
}
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value)))
bad("Widget default options do not match their schema");
if (schema.type === "number" && typeof value !== "number") bad("Widget default options do not match their schema");
if (schema.type === "string" && typeof value !== "string") bad("Widget default options do not match their schema");
if (schema.type === "boolean" && typeof value !== "boolean") bad("Widget default options do not match their schema");
if (schema.type === "null" && value !== null) bad("Widget default options do not match their schema");
}
function collectDynamicOptionRequestIds(schema, result) {
if (!isObject(schema)) return;
const metadata = schema["x-homarr"];
if (isObject(metadata) && isObject(metadata.optionsSource)) result.add(metadata.optionsSource.requestId);
if (isObject(schema.properties))
for (const child of Object.values(schema.properties)) collectDynamicOptionRequestIds(child, result);
if (schema.items !== undefined) collectDynamicOptionRequestIds(schema.items, result);
for (const branch of ["if", "then", "else"])
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
}
function containsCredential(value, key) {
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
if (!isObject(value)) return false;
if (Object.keys(value).length === 1 && typeof value.$param === "string") return false;
return Object.entries(value).some(([childKey, child]) => containsCredential(child, childKey));
}
function containsPrototypeKey(value) {
if (Array.isArray(value)) return value.some(containsPrototypeKey);
if (!isObject(value)) return false;
return Object.entries(value).some(
([key, child]) =>
["__proto__", "prototype", "constructor"].includes(key.toLowerCase()) || containsPrototypeKey(child),
);
}
function validateTemplate(template) {
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
if (
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test(
template,
)
)
bad("Widget template requests a blocked capability");
}
function validateTemplateRequests(template, requests) {
const byId = new Map(requests.map((request) => [request.id, request]));
const componentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
let match;
while ((match = componentPattern.exec(template)) !== null) {
const idMatch = match[2].match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
const requestId = idMatch && (idMatch[1] || idMatch[2]);
if (!requestId) bad(`${match[1]} must use a literal requestId`);
const request = byId.get(requestId);
const expectedKind = match[1] === "SubFetch" ? "query" : "action";
if (!request || request.kind !== expectedKind) bad(`${match[1]} references an invalid request`);
}
}
function validateTemplateBindings(template) {
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
let match;
while ((match = bindingPattern.exec(template)) !== null) {
const inputName = match[1] || match[2];
if (!inputName) bad("bind must use a literal input name");
if (!validBindingId(inputName)) bad("bind must use a valid input name");
}
}
function validateSubmission(record) {
const content = record.getString("content");
let widget;
try {
widget = JSON.parse(content);
} catch {
bad("Widget content is not valid JSON");
}
if (
!isObject(widget) ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
!widget.name.trim() ||
widget.name.length > 128 ||
(widget.description !== undefined && (typeof widget.description !== "string" || widget.description.length > 512)) ||
(widget.iconUrl !== undefined && (!validHttpUrl(widget.iconUrl) || /^https?:\/\/[^/]*@/i.test(widget.iconUrl))) ||
Object.keys(widget).some((key) => !TOP_LEVEL_FIELDS.includes(key)) ||
!Array.isArray(widget.sources) ||
widget.sources.length < 1 ||
widget.sources.length > 8 ||
!Array.isArray(widget.requests) ||
widget.requests.length > 64 ||
!isObject(widget.defaultOptions)
)
bad("Widget does not match homarr-custom-widget-v2");
validateOptionsSchema(widget.optionsSchema, true, true);
validateOptionsValue(widget.optionsSchema, widget.defaultOptions);
if (
widget.iconUrl &&
/[?&](?:authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)=/i.test(
widget.iconUrl,
)
)
bad("Widget icon URLs must not contain credentials");
const sourceIds = new Set();
for (const source of widget.sources) {
validateSource(source);
if (sourceIds.has(source.id)) bad("Widget source IDs must be unique");
sourceIds.add(source.id);
}
const requestIds = new Set();
for (const request of widget.requests) {
validateRequest(request, sourceIds);
if (requestIds.has(request.id)) bad("Widget request IDs must be unique");
requestIds.add(request.id);
}
for (const request of widget.requests)
for (const target of request.invalidates || []) {
const targetRequest = widget.requests.find((candidate) => candidate.id === target);
if (!targetRequest || targetRequest.kind !== "query") bad("Widget invalidates an unknown or non-query request");
}
for (const request of widget.requests) {
if (request.auth !== "inherit") continue;
const source = widget.sources.find((candidate) => candidate.id === request.sourceId);
if (
source.auth.type === "apiKeyHeader" &&
Object.keys(request.staticHeaders || {}).some(
(name) => name.toLowerCase() === source.auth.headerName.toLowerCase(),
)
)
bad("Widget request overrides source authentication");
if (
source.auth.type === "apiKeyQuery" &&
Object.keys(request.queryTemplate || {}).some(
(name) => name.toLowerCase() === source.auth.parameterName.toLowerCase(),
)
)
bad("Widget request overrides source authentication");
}
const dynamicOptionRequestIds = new Set();
collectDynamicOptionRequestIds(widget.optionsSchema, dynamicOptionRequestIds);
for (const requestId of dynamicOptionRequestIds) {
const request = widget.requests.find((candidate) => candidate.id === requestId);
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
}
validateTemplate(widget.template);
validateTemplateRequests(widget.template, widget.requests);
validateTemplateBindings(widget.template);
for (const request of widget.requests) {
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
bad("Widget static headers must not contain credentials");
}
}
if (
CREDENTIAL_TEXT.test(widget.template) ||
containsCredential(widget.defaultOptions, "") ||
widget.requests.some(
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
)
)
bad("Widget exports must not contain credentials");
if (
containsPrototypeKey(widget.optionsSchema) ||
containsPrototypeKey(widget.defaultOptions) ||
widget.requests.some(
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
)
)
bad("Widget contains a prototype-pollution key");
record.set("contentHash", $security.sha256(content));
}
function isAdmin(app, userId) {
try {
app.findFirstRecordByFilter("workshop_admins", "user = {:user}", { user: userId });
return true;
} catch {
return false;
}
}
function snapshot(record) {
const data = record.publicExport();
if (typeof data.content === "string") {
data.contentHash = $security.sha256(data.content);
delete data.content;
}
return JSON.stringify(data);
}
function audit(app, actor, action, values) {
const record = new Record(app.findCollectionByNameOrId("workshop_admin_actions"));
record.set("actor", actor.id);
record.set("action", action);
Object.entries(values).forEach(([key, value]) => record.set(key, value));
app.save(record);
}
module.exports = { audit, isAdmin, snapshot, validateSubmission };
-78
View File
@@ -1,78 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (!e.auth) throw new UnauthorizedError("Authentication is required");
utils.validateSubmission(e.record);
const user = e.app.findRecordById("users", e.auth.id);
e.record.set("author", e.auth.id);
e.record.set("authorName", user.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("revision", original.getInt("revision") + 1);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordDeleteRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const adminDelete = e.auth && utils.isAdmin(e.app, e.auth.id) && e.record.getString("author") !== e.auth.id;
const snapshot = adminDelete ? utils.snapshot(e.record) : "";
e.next();
if (adminDelete)
utils.audit(e.app, e.auth, "delete_submission", {
submissionId: e.record.id,
reason: "Deleted by Workshop administrator",
snapshot,
});
}, "submissions");
onRecordCreateRequest((e) => {
if (!e.auth) throw new UnauthorizedError("Authentication is required");
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
if (!e.auth) throw new UnauthorizedError("Authentication is required");
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const original = e.app.findRecordById("reports", e.record.id);
if (e.record.getString("status") !== "dismissed") throw new BadRequestError("Reports can only be dismissed");
e.record.set("submission", original.get("submission"));
e.record.set("reporter", original.get("reporter"));
e.record.set("category", original.get("category"));
e.record.set("explanation", original.get("explanation"));
e.next();
if (original.getString("status") !== "dismissed") {
utils.audit(e.app, e.auth, "dismiss_report", {
submissionId: original.getString("submission"),
reportId: original.id,
reason: e.record.getString("dismissalReason") || "Dismissed by Workshop administrator",
snapshot: utils.snapshot(original),
});
}
}, "reports");