Initial commit: OpenClaw Mattermost Extension
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user