Initial commit: OpenClaw Mattermost Extension

This commit is contained in:
2026-04-10 21:57:29 -07:00
commit 2072259fb6
126 changed files with 26403 additions and 0 deletions
+385
View File
@@ -0,0 +1,385 @@
import {
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedMattermostAccount } from "./mattermost/accounts.js";
import type { MattermostAccountConfig, MattermostConfig } from "./types.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type SecurityAuditFinding = {
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
};
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
if (!Array.isArray(list)) {
return [];
}
return list.map((value) => String(value).trim()).filter(Boolean);
}
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
if (value === true || value === false || value === "auto") {
return value;
}
return undefined;
}
function isHttpsUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:";
} catch {
return false;
}
}
function isValidMattermostId(id: string): boolean {
// Mattermost IDs are 26-character alphanumeric strings
const normalized = id
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
return /^[a-z0-9]{26}$/.test(normalized);
}
function isMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
if (isValidMattermostId(text)) {
return false;
}
return true;
}
function hasEnvVarReference(value: string | undefined): boolean {
if (!value) return false;
// Check for common env var patterns: $VAR, ${VAR}, %VAR%
return /\$\w+|\$\{[^}]+\}|%[^%]+%/.test(value);
}
function hasHardcodedSecret(value: string | undefined): boolean {
if (!value) return false;
// Mattermost tokens are typically 26+ character alphanumeric
// Pattern: looks like a token but not an env reference
const trimmed = value.trim();
if (trimmed.length < 20) return false;
// Check if it looks like a token (alphanumeric with possible hyphens/underscores)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) return false;
// Check for common env var patterns that would indicate it's not hardcoded
if (hasEnvVarReference(trimmed)) return false;
return true;
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
/**
* Collect security audit findings for Mattermost configuration.
* This function performs comprehensive security checks including:
* - PAT (Personal Access Token) security (env var usage, no hardcoding)
* - HTTPS enforcement verification
* - Input validation audit
* - File download restrictions check
* - Token permission checks
* - Config migration detection
* - Warnings for insecure configurations
*/
export async function collectMattermostSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedMattermostAccount;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const mmCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
// ===== 1. PAT SECURITY CHECKS =====
// Check 1.1: Bot token should use environment variables
if (params.account.botToken && params.account.botTokenSource === "config") {
const botToken = params.account.botToken;
if (hasHardcodedSecret(botToken)) {
findings.push({
checkId: "channels.mattermost.pat.hardcoded_token",
severity: "critical",
title: "Hardcoded Mattermost bot token detected",
detail: `The bot token for account "${accountId}" appears to be hardcoded in configuration. Hardcoded credentials pose a security risk and may be exposed in version control or logs.`,
remediation: "Move the token to the MATTERMOST_BOT_TOKEN environment variable or use a secret management system.",
});
} else if (botToken.length < 20) {
findings.push({
checkId: "channels.mattermost.pat.short_token",
severity: "warn",
title: "Short Mattermost bot token detected",
detail: `The bot token for account "${accountId}" appears unusually short (${botToken.length} chars). This may indicate an invalid or incomplete token.`,
remediation: "Verify the bot token is complete and valid.",
});
}
}
// Check 1.2: No bot token configured
if (!params.account.botToken) {
findings.push({
checkId: "channels.mattermost.pat.missing_token",
severity: "critical",
title: "No Mattermost bot token configured",
detail: `Account "${accountId}" has no bot token configured. The bot will not be able to authenticate with the Mattermost server.`,
remediation: "Set the MATTERMOST_BOT_TOKEN environment variable or configure channels.mattermost.botToken.",
});
}
// ===== 2. HTTPS ENFORCEMENT CHECK =====
// Check 2.1: Base URL should use HTTPS
if (params.account.baseUrl) {
if (!isHttpsUrl(params.account.baseUrl)) {
findings.push({
checkId: "channels.mattermost.https.insecure_url",
severity: "critical",
title: "Mattermost base URL uses HTTP (insecure)",
detail: `The base URL "${sanitizeForLog(params.account.baseUrl)}" for account "${accountId}" uses HTTP instead of HTTPS. This exposes all communications (including authentication tokens) to interception.`,
remediation: "Change the baseUrl to use HTTPS, e.g., https://chat.example.com",
});
}
} else {
findings.push({
checkId: "channels.mattermost.https.missing_url",
severity: "critical",
title: "No Mattermost base URL configured",
detail: `Account "${accountId}" has no base URL configured. The bot will not be able to connect to the Mattermost server.`,
remediation: "Set the MATTERMOST_URL environment variable or configure channels.mattermost.baseUrl with a HTTPS URL.",
});
}
// ===== 3. INPUT VALIDATION AUDIT =====
// Check 3.1: Validate interaction callback base URL
const interactions = mmCfg.interactions as { callbackBaseUrl?: string; allowedSourceIps?: string[] } | undefined;
if (interactions?.callbackBaseUrl) {
if (!isHttpsUrl(interactions.callbackBaseUrl)) {
findings.push({
checkId: "channels.mattermost.input.insecure_callback",
severity: "critical",
title: "Mattermost interaction callback uses HTTP",
detail: `The interaction callbackBaseUrl "${sanitizeForLog(interactions.callbackBaseUrl)}" uses HTTP. This exposes interaction payloads to interception.`,
remediation: "Configure interaction.callbackBaseUrl to use HTTPS.",
});
}
// Check for allowed source IPs
if (!interactions.allowedSourceIps || interactions.allowedSourceIps.length === 0) {
findings.push({
checkId: "channels.mattermost.input.no_source_ip_restriction",
severity: "warn",
title: "No IP restrictions on Mattermost interaction callbacks",
detail: "Interaction callbacks have no source IP allowlist configured. This may allow spoofed requests from unauthorized sources.",
remediation: "Configure interactions.allowedSourceIps to restrict callback sources to your Mattermost server IP/CIDR.",
});
}
}
// ===== 4. FILE DOWNLOAD RESTRICTIONS CHECK =====
// Check 4.1: Private network access
if (mmCfg.allowPrivateNetwork === true) {
findings.push({
checkId: "channels.mattermost.network.private_access_enabled",
severity: "warn",
title: "Private network access enabled for Mattermost",
detail: "The allowPrivateNetwork setting is enabled, allowing the bot to fetch from private/internal IP addresses. This is required for self-hosted Mattermost but increases attack surface.",
remediation: "If not using self-hosted Mattermost on LAN/VPN, set allowPrivateNetwork to false.",
});
}
// ===== 5. TOKEN PERMISSION CHECKS =====
// Check 5.1: Slash command security
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "mattermost",
providerSetting: coerceNativeSetting(
(mmCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "mattermost",
providerSetting: coerceNativeSetting(
(mmCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashCommandEnabled =
nativeEnabled ||
nativeSkillsEnabled ||
(mmCfg.commands as { enabled?: unknown } | undefined)?.enabled === true;
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.mattermost.commands.access_groups_disabled",
severity: "critical",
title: "Mattermost slash commands bypass access groups",
detail: "Mattermost slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
}
// Check for allowlists on slash commands
const allowFromRaw = mmCfg.allowFrom;
// eslint-disable-next-line no-process-env
const storeAllowFrom = await readChannelAllowFromStore("mattermost", process.env, accountId).catch(
() => [],
);
const ownerAllowFromConfigured =
normalizeAllowFromList([...(allowFromRaw || []), ...storeAllowFrom]).length > 0;
if (!ownerAllowFromConfigured) {
findings.push({
checkId: "channels.mattermost.commands.no_allowlist",
severity: "warn",
title: "Mattermost slash commands have no allowlist",
detail: "Mattermost slash/native commands are enabled, but no owner allowFrom list is configured; commands may be rejected for everyone or accepted from anyone depending on dmPolicy.",
remediation: "Configure channels.mattermost.allowFrom with authorized user IDs, or use pairing to approve users.",
});
}
}
// ===== 6. CONFIG MIGRATION DETECTION =====
// Check 6.1: Legacy dmPolicy migration needed
const legacyDmPolicy = (mmCfg as { dm?: { policy?: string } }).dm?.policy;
if (legacyDmPolicy !== undefined && mmCfg.dmPolicy === undefined) {
findings.push({
checkId: "channels.mattermost.config.legacy_dm_policy",
severity: "info",
title: "Legacy dm.policy configuration detected",
detail: "The configuration uses the legacy dm.policy setting which should be migrated to dmPolicy.",
remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.",
});
}
// Check 6.2: Legacy allowFrom migration needed
const legacyAllowFrom = (mmCfg as { dm?: { allowFrom?: unknown[] } }).dm?.allowFrom;
if (legacyAllowFrom !== undefined && mmCfg.allowFrom === undefined) {
findings.push({
checkId: "channels.mattermost.config.legacy_allow_from",
severity: "info",
title: "Legacy dm.allowFrom configuration detected",
detail: "The configuration uses the legacy dm.allowFrom setting which should be migrated to top-level allowFrom.",
remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.",
});
}
// ===== 7. MUTABLE ALLOWLIST WARNINGS =====
// Check 7.1: Mutable entries in allowFrom without dangerous name matching
const allowFromList = mmCfg.allowFrom || [];
const mutableAllowEntries = normalizeAllowFromList(allowFromList).filter(isMutableAllowEntry);
const dangerousNameMatchingEnabled = mmCfg.dangerouslyAllowNameMatching === true;
if (mutableAllowEntries.length > 0 && !dangerousNameMatchingEnabled) {
const entriesPreview = mutableAllowEntries.slice(0, 3).join(", ");
const moreCount = mutableAllowEntries.length > 3 ? ` (+${mutableAllowEntries.length - 3} more)` : "";
findings.push({
checkId: "channels.mattermost.allowlist.mutable_entries",
severity: "warn",
title: "Mutable allowlist entries detected",
detail: `Found ${mutableAllowEntries.length} mutable (non-ID) entries in allowFrom: "${entriesPreview}${moreCount}". These entries match by username/display name which can change, potentially allowing unauthorized access.`,
remediation: "Option A: Enable channels.mattermost.dangerouslyAllowNameMatching=true as a break-glass measure. Option B: Resolve names to stable Mattermost IDs and update the allowlist entries.",
});
}
// ===== 8. ACTION PERMISSIONS CHECK =====
const actions = mmCfg.actions || {};
// Check 8.1: Delete action enabled
const deleteEnabled = actions.delete ?? true;
if (deleteEnabled !== false) {
findings.push({
checkId: "channels.mattermost.actions.delete_enabled",
severity: "info",
title: "Message delete action is enabled",
detail: "The delete action is enabled, allowing the bot to delete messages. This is generally safe but increases the attack surface.",
remediation: "If message deletion is not needed, disable with channels.mattermost.actions.delete=false.",
});
}
// ===== 9. GROUP POLICY SECURITY =====
const groupPolicy = mmCfg.groupPolicy;
if (groupPolicy === "open") {
findings.push({
checkId: "channels.mattermost.groups.open_policy",
severity: "warn",
title: "Open group policy configured",
detail: "The groupPolicy is set to 'open', allowing any Mattermost user to interact with the bot in group channels. This increases the attack surface.",
remediation: "Consider setting groupPolicy to 'allowlist' and configuring groupAllowFrom with authorized users.",
});
}
// ===== 10. DM POLICY SECURITY =====
const dmPolicy = mmCfg.dmPolicy;
if (dmPolicy === "open") {
findings.push({
checkId: "channels.mattermost.dm.open_policy",
severity: "warn",
title: "Open DM policy configured",
detail: "The dmPolicy is set to 'open', allowing any Mattermost user to send direct messages to the bot. This increases the attack surface.",
remediation: "Consider setting dmPolicy to 'allowlist' or 'pairing' to restrict DM access.",
});
} else if (dmPolicy === "pairing") {
findings.push({
checkId: "channels.mattermost.dm.pairing_mode",
severity: "info",
title: "DM pairing mode enabled",
detail: "The dmPolicy is set to 'pairing', requiring users to explicitly approve access before sending DMs to the bot. This is the recommended secure configuration.",
});
}
return findings;
}
/**
* Check if an allowlist entry is mutable (uses name/username instead of stable ID).
* Exported for use in doctor.ts
*/
export function isMattermostMutableAllowEntry(raw: string): boolean {
return isMutableAllowEntry(raw);
}
/**
* Collect security audit findings for all enabled Mattermost accounts.
*/
export async function collectAllMattermostSecurityAuditFindings(params: {
cfg: OpenClawConfig;
listAccounts: (cfg: OpenClawConfig) => ResolvedMattermostAccount[];
}): Promise<SecurityAuditFinding[]> {
const accounts = params.listAccounts(params.cfg);
const allFindings: SecurityAuditFinding[] = [];
for (const account of accounts) {
if (!account.enabled) continue;
const findings = await collectMattermostSecurityAuditFindings({
cfg: params.cfg,
accountId: account.accountId,
account,
});
allFindings.push(...findings);
}
return allFindings;
}