Files
homarr/packages/custom-widgets/src/core/definition-security.ts
T

221 lines
7.9 KiB
TypeScript

import type { z } from "zod/v4";
type DefinitionPath = Array<string | number>;
const harmlessCredentialSettings = new Set([
"anonymous",
"authentication",
"basic",
"bearer",
"configured",
"default",
"disabled",
"enabled",
"example",
"false",
"inherit",
"missing",
"none",
"optional",
"placeholder",
"public",
"redacted",
"required",
"separate",
"separately",
"source",
"true",
"unset",
]);
const commonCredentialLiteralPattern =
/\b(?:sk|pk|rk)-(?:[A-Za-z0-9][A-Za-z0-9._-]{7,})\b|\b(?:sk_(?:live|test)|github_pat|glpat|gh[pousr]|hf_|xox[baprs])-?[A-Za-z0-9._-]{8,}\b|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\bAIza[0-9A-Za-z_-]{20,}\b|\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/giu;
const authorizationSchemePattern = /\b(bearer|basic)([\s:_-]+)(["']?)([A-Za-z0-9._~+/%=-]{8,})/giu;
const credentialAssignmentPattern =
/\b(authorization|auth(?:entication)?(?:[ _-]?(?:tokens?|keys?|credentials?))?|credentials?|api[ _-]?keys?|passwords?|passwds?|secrets?|tokens?|access[ _-]?(?:tokens?|keys?)|refresh[ _-]?tokens?|client[ _-]?secrets?|private[ _-]?keys?|signing[ _-]?keys?)((?:["']?\s*[:=]\s*["']?))([^\s,;"'}<>]+)/giu;
export function validateCredentialFreeExport(definition: Record<string, unknown>, ctx: z.RefinementCtx) {
const { secrets: _secrets, ...exportedDefinition } = definition;
inspect(exportedDefinition, [], ctx);
inspectRequestPathQueries(definition.requests, ctx);
inspectUrlQuery(definition.iconUrl, ["iconUrl"], "Widget icon URLs", ctx);
}
function inspect(value: unknown, path: DefinitionPath, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
if (typeof value === "string") {
if (containsCustomWidgetCredentialLiteral(value)) {
ctx.addIssue({ code: "custom", path, message: "Credentials must use source authentication" });
}
return;
}
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
const keyRisk = getCustomWidgetCredentialKeyRisk(key);
if (
keyRisk === "strong" &&
!isSchemaAuthenticationControl(childPath) &&
hasCredentialFieldValue(child) &&
!isHarmlessCustomWidgetCredentialSetting(child)
) {
ctx.addIssue({ code: "custom", path: childPath, message: "Credentials must use source authentication" });
continue;
}
if (keyRisk === "ambiguous" && typeof child === "string" && containsCustomWidgetCredentialLiteral(child)) {
ctx.addIssue({ code: "custom", path: childPath, message: "Credentials must use source authentication" });
continue;
}
inspect(child, childPath, ctx);
}
}
function inspectRequestPathQueries(value: unknown, ctx: z.RefinementCtx): void {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const [requestId, candidate] of Object.entries(value)) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
const path = (candidate as Record<string, unknown>).path;
if (typeof path !== "string") continue;
const queryStart = path.indexOf("?");
if (queryStart < 0) continue;
const query = path.slice(queryStart + 1);
for (const [key, queryValue] of parseQueryEntries(query)) {
const keyRisk = getCustomWidgetCredentialKeyRisk(key);
if (
(keyRisk === "strong" && queryValue.length > 0 && !isHarmlessCustomWidgetCredentialSetting(queryValue)) ||
containsCustomWidgetCredentialLiteral(queryValue)
) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "path"],
message: "Request query credentials must use source authentication",
});
break;
}
}
}
}
function inspectUrlQuery(value: unknown, path: DefinitionPath, label: string, ctx: z.RefinementCtx): void {
if (typeof value !== "string" || !URL.canParse(value)) return;
const url = new URL(value);
if (
parseQueryEntries(url.search).some(
([key, queryValue]) =>
(getCustomWidgetCredentialKeyRisk(key) === "strong" && queryValue.length > 0) ||
containsCustomWidgetCredentialLiteral(queryValue),
)
) {
ctx.addIssue({ code: "custom", path, message: `${label} cannot contain credentials` });
}
}
function parseQueryEntries(search: string): Array<[string, string]> {
const query = search.startsWith("?") ? search.slice(1) : search;
if (!query) return [];
return query.split("&").map((entry) => {
const separatorIndex = entry.indexOf("=");
const key = separatorIndex < 0 ? entry : entry.slice(0, separatorIndex);
const value = separatorIndex < 0 ? "" : entry.slice(separatorIndex + 1);
return [decodeQueryPart(key), decodeQueryPart(value)];
});
}
function decodeQueryPart(value: string): string {
try {
return decodeURIComponent(value.replaceAll("+", " "));
} catch {
return value;
}
}
export function getCustomWidgetCredentialKeyRisk(key: string): "strong" | "ambiguous" | null {
const normalized = key
.replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
.replace(/[^A-Za-z0-9]+/gu, " ")
.trim()
.toLowerCase();
if (!normalized) return null;
const phrases = [
"authorization",
"authentication",
"auth",
"credential",
"credentials",
"api key",
"api keys",
"password",
"passwords",
"passwd",
"passwds",
"secret",
"secrets",
"token",
"tokens",
"access token",
"access tokens",
"access key",
"access keys",
"refresh token",
"refresh tokens",
"client secret",
"client secrets",
"private key",
"private keys",
"signing key",
"signing keys",
];
if (phrases.some((phrase) => normalized === phrase || normalized.endsWith(` ${phrase}`))) return "strong";
return normalized === "key" || normalized.endsWith(" key") || normalized === "keys" || normalized.endsWith(" keys")
? "ambiguous"
: null;
}
export function isHarmlessCustomWidgetCredentialSetting(value: unknown): boolean {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return true;
return typeof value === "string" && harmlessCredentialSettings.has(value.trim().toLowerCase());
}
export function containsCustomWidgetCredentialLiteral(value: string): boolean {
return redactCustomWidgetCredentialLiterals(value) !== value;
}
export function redactCustomWidgetCredentialLiterals(value: string): string {
return value
.replace(commonCredentialLiteralPattern, "[REDACTED]")
.replace(authorizationSchemePattern, (match, scheme: string, separator: string, quote: string, candidate: string) =>
isHarmlessCustomWidgetCredentialSetting(candidate) ? match : `${scheme}${separator}${quote}[REDACTED]`,
)
.replace(credentialAssignmentPattern, (match, key: string, separator: string, candidate: string) =>
isHarmlessCustomWidgetCredentialSetting(candidate) ? match : `${key}${separator}[REDACTED]`,
);
}
function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
return (
path.length === 3 &&
path[2] === "auth" &&
(path[0] === "sources" || path[0] === "requests") &&
typeof path[1] === "string"
);
}
function hasCredentialFieldValue(value: unknown): boolean {
if (value === null || value === undefined || value === "") return false;
return true;
}
export function validatePrototypeKeys(value: unknown, ctx: z.RefinementCtx) {
walk(value, [], ctx);
}
function walk(value: unknown, path: Array<string | number>, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => walk(entry, [...path, index], ctx));
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (["__proto__", "prototype", "constructor"].includes(key))
ctx.addIssue({ code: "custom", path: [...path, key], message: "Unsafe object key" });
walk(child, [...path, key], ctx);
}
}