mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(security): tighten elevated allowFrom sender matching
This commit is contained in:
@@ -38,7 +38,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
|
- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
|
||||||
- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
|
- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
|
||||||
- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
|
- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
|
||||||
- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. (#11022) Thanks @coygeek.
|
- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. This ships in the next npm release. Thanks @jiseoung for reporting.
|
||||||
- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64.
|
- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64.
|
||||||
- Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer.
|
- Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer.
|
||||||
- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design.
|
- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design.
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ title: "Elevated Mode"
|
|||||||
|
|
||||||
- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it).
|
- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it).
|
||||||
- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
|
- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
|
||||||
|
- Unprefixed allowlist entries match sender-scoped identity values only (`SenderId`, `SenderE164`, `From`); recipient routing fields are never used for elevated authorization.
|
||||||
|
- Mutable sender metadata requires explicit prefixes:
|
||||||
|
- `name:<value>` matches `SenderName`
|
||||||
|
- `username:<value>` matches `SenderUsername`
|
||||||
|
- `tag:<value>` matches `SenderTag`
|
||||||
|
- `id:<value>`, `from:<value>`, `e164:<value>` are available for explicit identity targeting
|
||||||
- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict).
|
- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict).
|
||||||
- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists).
|
- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists).
|
||||||
- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback.
|
- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe("trigger handling", () => {
|
|||||||
it("uses tools.elevated.allowFrom.discord for elevated approval", async () => {
|
it("uses tools.elevated.allowFrom.discord for elevated approval", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
cfg.tools = { elevated: { allowFrom: { discord: ["steipete"] } } };
|
cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } };
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function buildContext(overrides?: Partial<MsgContext>): MsgContext {
|
|||||||
return {
|
return {
|
||||||
Provider: "whatsapp",
|
Provider: "whatsapp",
|
||||||
Surface: "whatsapp",
|
Surface: "whatsapp",
|
||||||
|
SenderId: "+15550001111",
|
||||||
From: "whatsapp:+15550001111",
|
From: "whatsapp:+15550001111",
|
||||||
SenderE164: "+15550001111",
|
SenderE164: "+15550001111",
|
||||||
To: "+15559990000",
|
To: "+15559990000",
|
||||||
@@ -55,4 +56,39 @@ describe("resolveElevatedPermissions", () => {
|
|||||||
key: "tools.elevated.allowFrom.whatsapp",
|
key: "tools.elevated.allowFrom.whatsapp",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not authorize untyped mutable sender fields", () => {
|
||||||
|
const result = resolveElevatedPermissions({
|
||||||
|
cfg: buildConfig(["owner-display-name"]),
|
||||||
|
agentId: "main",
|
||||||
|
provider: "whatsapp",
|
||||||
|
ctx: buildContext({
|
||||||
|
SenderName: "owner-display-name",
|
||||||
|
SenderUsername: "owner-display-name",
|
||||||
|
SenderTag: "owner-display-name",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.failures).toContainEqual({
|
||||||
|
gate: "allowFrom",
|
||||||
|
key: "tools.elevated.allowFrom.whatsapp",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authorizes mutable sender fields only with explicit prefix", () => {
|
||||||
|
const result = resolveElevatedPermissions({
|
||||||
|
cfg: buildConfig(["username:owner_username"]),
|
||||||
|
agentId: "main",
|
||||||
|
provider: "whatsapp",
|
||||||
|
ctx: buildContext({
|
||||||
|
SenderUsername: "owner_username",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.failures).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
|||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
export { formatElevatedUnavailableMessage } from "./elevated-unavailable.js";
|
export { formatElevatedUnavailableMessage } from "./elevated-unavailable.js";
|
||||||
|
|
||||||
|
type ExplicitElevatedAllowField = "id" | "from" | "e164" | "name" | "username" | "tag";
|
||||||
|
|
||||||
|
const EXPLICIT_ELEVATED_ALLOW_FIELDS = new Set<ExplicitElevatedAllowField>([
|
||||||
|
"id",
|
||||||
|
"from",
|
||||||
|
"e164",
|
||||||
|
"name",
|
||||||
|
"username",
|
||||||
|
"tag",
|
||||||
|
]);
|
||||||
|
|
||||||
function normalizeAllowToken(value?: string) {
|
function normalizeAllowToken(value?: string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
@@ -48,7 +59,120 @@ function resolveElevatedAllowList(
|
|||||||
return Array.isArray(value) ? value : fallbackAllowFrom;
|
return Array.isArray(value) ? value : fallbackAllowFrom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExplicitElevatedAllowEntry(
|
||||||
|
entry: string,
|
||||||
|
): { field: ExplicitElevatedAllowField; value: string } | null {
|
||||||
|
const separatorIndex = entry.indexOf(":");
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fieldRaw = entry.slice(0, separatorIndex).trim().toLowerCase();
|
||||||
|
if (!EXPLICIT_ELEVATED_ALLOW_FIELDS.has(fieldRaw as ExplicitElevatedAllowField)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = entry.slice(separatorIndex + 1).trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
field: fieldRaw as ExplicitElevatedAllowField,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTokenVariants(tokens: Set<string>, value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokens.add(value);
|
||||||
|
const normalized = normalizeAllowToken(value);
|
||||||
|
if (normalized) {
|
||||||
|
tokens.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProviderFormattedTokens(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
provider: string;
|
||||||
|
accountId?: string;
|
||||||
|
values: string[];
|
||||||
|
tokens: Set<string>;
|
||||||
|
}) {
|
||||||
|
const normalizedProvider = normalizeChannelId(params.provider);
|
||||||
|
const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined;
|
||||||
|
const formatted = dock?.config?.formatAllowFrom
|
||||||
|
? dock.config.formatAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
|
allowFrom: params.values,
|
||||||
|
})
|
||||||
|
: params.values.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
for (const entry of formatted) {
|
||||||
|
addTokenVariants(params.tokens, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesProviderFormattedTokens(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
provider: string;
|
||||||
|
accountId?: string;
|
||||||
|
value: string;
|
||||||
|
includeStripped?: boolean;
|
||||||
|
tokens: Set<string>;
|
||||||
|
}): boolean {
|
||||||
|
const probeTokens = new Set<string>();
|
||||||
|
const values = params.includeStripped
|
||||||
|
? [params.value, stripSenderPrefix(params.value)].filter(Boolean)
|
||||||
|
: [params.value];
|
||||||
|
addProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.accountId,
|
||||||
|
values,
|
||||||
|
tokens: probeTokens,
|
||||||
|
});
|
||||||
|
for (const token of probeTokens) {
|
||||||
|
if (params.tokens.has(token)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMutableTokens(value?: string): Set<string> {
|
||||||
|
const tokens = new Set<string>();
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
addTokenVariants(tokens, trimmed);
|
||||||
|
const slugged = slugAllowToken(trimmed);
|
||||||
|
if (slugged) {
|
||||||
|
addTokenVariants(tokens, slugged);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesMutableTokens(value: string, tokens: Set<string>): boolean {
|
||||||
|
if (!value || tokens.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const probes = new Set<string>();
|
||||||
|
addTokenVariants(probes, value);
|
||||||
|
const slugged = slugAllowToken(value);
|
||||||
|
if (slugged) {
|
||||||
|
addTokenVariants(probes, slugged);
|
||||||
|
}
|
||||||
|
for (const probe of probes) {
|
||||||
|
if (tokens.has(probe)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isApprovedElevatedSender(params: {
|
function isApprovedElevatedSender(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
provider: string;
|
provider: string;
|
||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
allowFrom?: AgentElevatedAllowFromConfig;
|
allowFrom?: AgentElevatedAllowFromConfig;
|
||||||
@@ -71,48 +195,124 @@ function isApprovedElevatedSender(params: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = new Set<string>();
|
const senderIdTokens = new Set<string>();
|
||||||
const addToken = (value?: string) => {
|
const senderFromTokens = new Set<string>();
|
||||||
if (!value) {
|
const senderE164Tokens = new Set<string>();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokens.add(trimmed);
|
|
||||||
const normalized = normalizeAllowToken(trimmed);
|
|
||||||
if (normalized) {
|
|
||||||
tokens.add(normalized);
|
|
||||||
}
|
|
||||||
const slugged = slugAllowToken(trimmed);
|
|
||||||
if (slugged) {
|
|
||||||
tokens.add(slugged);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
addToken(params.ctx.SenderName);
|
if (params.ctx.SenderId?.trim()) {
|
||||||
addToken(params.ctx.SenderUsername);
|
addProviderFormattedTokens({
|
||||||
addToken(params.ctx.SenderTag);
|
cfg: params.cfg,
|
||||||
addToken(params.ctx.SenderE164);
|
provider: params.provider,
|
||||||
addToken(params.ctx.From);
|
accountId: params.ctx.AccountId,
|
||||||
addToken(stripSenderPrefix(params.ctx.From));
|
values: [params.ctx.SenderId, stripSenderPrefix(params.ctx.SenderId)].filter(Boolean),
|
||||||
|
tokens: senderIdTokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (params.ctx.From?.trim()) {
|
||||||
|
addProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
values: [params.ctx.From, stripSenderPrefix(params.ctx.From)].filter(Boolean),
|
||||||
|
tokens: senderFromTokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (params.ctx.SenderE164?.trim()) {
|
||||||
|
addProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
values: [params.ctx.SenderE164],
|
||||||
|
tokens: senderE164Tokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const senderIdentityTokens = new Set<string>([
|
||||||
|
...senderIdTokens,
|
||||||
|
...senderFromTokens,
|
||||||
|
...senderE164Tokens,
|
||||||
|
]);
|
||||||
|
|
||||||
for (const rawEntry of allowTokens) {
|
const senderNameTokens = buildMutableTokens(params.ctx.SenderName);
|
||||||
const entry = rawEntry.trim();
|
const senderUsernameTokens = buildMutableTokens(params.ctx.SenderUsername);
|
||||||
if (!entry) {
|
const senderTagTokens = buildMutableTokens(params.ctx.SenderTag);
|
||||||
|
|
||||||
|
for (const entry of allowTokens) {
|
||||||
|
const explicitEntry = parseExplicitElevatedAllowEntry(entry);
|
||||||
|
if (!explicitEntry) {
|
||||||
|
if (
|
||||||
|
matchesProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
value: entry,
|
||||||
|
includeStripped: true,
|
||||||
|
tokens: senderIdentityTokens,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const stripped = stripSenderPrefix(entry);
|
if (explicitEntry.field === "id") {
|
||||||
if (tokens.has(entry) || tokens.has(stripped)) {
|
if (
|
||||||
return true;
|
matchesProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
value: explicitEntry.value,
|
||||||
|
includeStripped: true,
|
||||||
|
tokens: senderIdTokens,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const normalized = normalizeAllowToken(stripped);
|
if (explicitEntry.field === "from") {
|
||||||
if (normalized && tokens.has(normalized)) {
|
if (
|
||||||
return true;
|
matchesProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
value: explicitEntry.value,
|
||||||
|
includeStripped: true,
|
||||||
|
tokens: senderFromTokens,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const slugged = slugAllowToken(stripped);
|
if (explicitEntry.field === "e164") {
|
||||||
if (slugged && tokens.has(slugged)) {
|
if (
|
||||||
|
matchesProviderFormattedTokens({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: params.provider,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
value: explicitEntry.value,
|
||||||
|
tokens: senderE164Tokens,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (explicitEntry.field === "name") {
|
||||||
|
if (matchesMutableTokens(explicitEntry.value, senderNameTokens)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (explicitEntry.field === "username") {
|
||||||
|
if (matchesMutableTokens(explicitEntry.value, senderUsernameTokens)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
explicitEntry.field === "tag" &&
|
||||||
|
matchesMutableTokens(explicitEntry.value, senderTagTokens)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +362,7 @@ export function resolveElevatedPermissions(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const fallbackAllowFrom = dockFallbackAllowFrom;
|
const fallbackAllowFrom = dockFallbackAllowFrom;
|
||||||
const globalAllowed = isApprovedElevatedSender({
|
const globalAllowed = isApprovedElevatedSender({
|
||||||
|
cfg: params.cfg,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
allowFrom: globalConfig?.allowFrom,
|
allowFrom: globalConfig?.allowFrom,
|
||||||
@@ -177,6 +378,7 @@ export function resolveElevatedPermissions(params: {
|
|||||||
|
|
||||||
const agentAllowed = agentConfig?.allowFrom
|
const agentAllowed = agentConfig?.allowFrom
|
||||||
? isApprovedElevatedSender({
|
? isApprovedElevatedSender({
|
||||||
|
cfg: params.cfg,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
allowFrom: agentConfig.allowFrom,
|
allowFrom: agentConfig.allowFrom,
|
||||||
|
|||||||
Reference in New Issue
Block a user