Medium severity6.5NVD Advisory· Published Apr 9, 2026· Updated Apr 16, 2026
CVE-2026-35627
CVE-2026-35627
Description
OpenClaw before 2026.3.22 performs cryptographic and dispatch operations on inbound Nostr direct messages before enforcing sender and pairing policy validation. Attackers can trigger unauthorized pre-authentication computation by sending crafted DM messages, enabling denial of service through resource exhaustion.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.22 | 2026.3.22 |
Affected products
1Patches
2630f1479c44fbuild: prepare 2026.3.23-2
3 files changed · +26 −24
CHANGELOG.md+24 −22 modified@@ -8,45 +8,47 @@ Docs: https://docs.openclaw.ai ### Changes -- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878) -- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev. -- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev. - ### Fixes -- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar. -- CLI/channel auth: auto-select the single login-capable configured channel for `channels login`/`logout` instead of relying on the outbound message-channel resolver, so env-only or non-auth channels no longer cause false ambiguity errors. (#53254) Thanks @BunsDev. -- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev. -- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version. -- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516. -- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha. -- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata. -- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue. -- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom -- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths. -- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00. -- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc. -- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon. -- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr. -- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi. - ## 2026.3.23 ### Breaking ### Changes +- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878) +- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev. +- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev. + ### Fixes +- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces. +- CLI/channel auth: auto-select the single configured login-capable channel for `channels login`/`logout`, harden channel ids against prototype-chain and control-character abuse, and fall back cleanly to catalog-backed channel installs, so channel auth works again for single-channel setups and on-demand channel installs. (#53254) Thanks @BunsDev. +- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516. +- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev. +- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar. +- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version. - Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc. - Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc. +- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov. +- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc. - ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034. - ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob. - ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc. +- Config/warnings: suppress the confusing “newer OpenClaw” warning when a config written by a same-base correction release like `2026.3.23-2` is read by `2026.3.23`, while still warning for truly newer or incompatible versions. +- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr. +- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi. +- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha. +- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon. +- Telegram/threading: populate `currentThreadTs` in the threading tool-context fallback for Telegram DM topics so thread-aware tools still receive the active topic context when the main thread metadata is missing. (#52217) +- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata. +- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue. +- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom +- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths. +- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00. +- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc. - Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc. - Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc. -- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc. -- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov. - Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry. - Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf. - Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
docs/.generated/plugin-sdk-api-baseline.json+1 −1 modified@@ -2620,7 +2620,7 @@ "exportName": "resolveCommandAuthorization", "kind": "function", "source": { - "line": 303, + "line": 440, "path": "src/auto-reply/command-auth.ts" } },
docs/.generated/plugin-sdk-api-baseline.jsonl+1 −1 modified@@ -287,7 +287,7 @@ {"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"} {"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"} {"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"} -{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":303,"sourcePath":"src/auto-reply/command-auth.ts"} +{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"} {"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"} {"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"} {"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
1ee9611079e8fix(nostr): enforce inbound dm policy before decrypt
9 files changed · +748 −28
CHANGELOG.md+1 −0 modified@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran. - Agents/default timeout: raise the shared default agent timeout from `600s` to `48h` so long-running ACP and agent sessions do not fail unless you configure a shorter limit. - Gateway/Linux: auto-detect nvm-managed Node TLS CA bundle needs before CLI startup and refresh installed services that are missing `NODE_EXTRA_CA_CERTS`. (#51146) Thanks @GodsBoy. - Android/pairing: resolve portless secure setup URLs to `443` while preserving direct cleartext gateway defaults and explicit `:80` manual endpoints in onboarding. (#43540) Thanks @fmercurio.
docs/channels/nostr.md+7 −0 modified@@ -130,6 +130,12 @@ Notes: - **open**: public inbound DMs (requires `allowFrom: ["*"]`). - **disabled**: ignore inbound DMs. +Enforcement notes: + +- Sender policy is checked before signature verification and NIP-04 decryption. +- Pairing replies are sent without processing the original DM body. +- Inbound DMs are rate-limited and oversized payloads are dropped before decrypt. + ### Allowlist example ```json5 @@ -234,6 +240,7 @@ docker run -p 7777:7777 ghcr.io/hoytech/strfry - Never commit private keys. - Use environment variables for keys. - Consider `allowlist` for production bots. +- Pairing and allowlist policy is enforced before decrypt, so unknown senders cannot force full crypto work. ## Limitations (MVP)
docs/channels/pairing.md+1 −1 modified@@ -36,7 +36,7 @@ openclaw pairing list telegram openclaw pairing approve telegram <CODE> ``` -Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`, `feishu`. +Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`, `feishu`, `nostr`. ### Where the state lives
extensions/nostr/README.md+6 −0 modified@@ -68,6 +68,10 @@ openclaw plugins install @openclaw/nostr - **open**: Anyone can message the bot (use with caution) - **disabled**: DMs are disabled +Policy enforcement happens before signature verification and NIP-04 decryption. +Unknown senders in `pairing` mode can receive a pairing reply, but their original DM body is not +processed unless approved. + ### Example: Allowlist Mode ```json @@ -113,6 +117,8 @@ docker run -p 7777:7777 ghcr.io/hoytech/strfry - Private keys are never logged - Event signatures are verified before processing +- Sender policy is checked before expensive crypto work +- Inbound DMs are rate-limited and oversized payloads are dropped before decrypt - Use environment variables for keys, never commit to config files - Consider using `allowlist` mode in production
extensions/nostr/src/channel.inbound.test.ts+170 −0 added@@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; +import type { PluginRuntime } from "../runtime-api.js"; +import { nostrPlugin } from "./channel.js"; +import { setNostrRuntime } from "./runtime.js"; +import { buildResolvedNostrAccount } from "./test-fixtures.js"; + +const mocks = vi.hoisted(() => ({ + normalizePubkey: vi.fn((value: string) => + value + .trim() + .replace(/^nostr:/i, "") + .toLowerCase(), + ), + startNostrBus: vi.fn(), +})); + +vi.mock("./nostr-bus.js", () => ({ + DEFAULT_RELAYS: ["wss://relay.example.com"], + getPublicKeyFromPrivate: vi.fn(() => "bot-pubkey"), + normalizePubkey: mocks.normalizePubkey, + startNostrBus: mocks.startNostrBus, +})); + +function createRuntimeHarness() { + const recordInboundSession = vi.fn(async () => {}); + const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "|a|b|" }); + }); + const runtime = { + channel: { + text: { + resolveMarkdownTableMode: vi.fn(() => "off"), + convertMarkdownTables: vi.fn((text: string) => `converted:${text}`), + }, + commands: { + shouldComputeCommandAuthorized: vi.fn(() => true), + }, + routing: { + resolveAgentRoute: vi.fn(({ accountId, peer }) => ({ + agentId: "agent-nostr", + accountId, + sessionKey: `nostr:${peer.id}`, + })), + }, + session: { + resolveStorePath: vi.fn(() => "/tmp/nostr-session-store"), + recordInboundSession, + }, + reply: { + formatAgentEnvelope: vi.fn(({ body }) => `envelope:${body}`), + resolveEnvelopeFormatOptions: vi.fn(() => ({ mode: "agent" })), + finalizeInboundContext: vi.fn((ctx) => ctx), + dispatchReplyWithBufferedBlockDispatcher, + }, + pairing: { + readAllowFromStore: vi.fn(async () => []), + upsertPairingRequest: vi.fn(async () => ({ code: "PAIR1234", created: true })), + }, + }, + } as unknown as PluginRuntime; + + return { + runtime, + recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher, + }; +} + +describe("nostr inbound gateway path", () => { + afterEach(() => { + mocks.normalizePubkey.mockClear(); + mocks.startNostrBus.mockReset(); + }); + + it("issues a pairing reply before decrypt for unknown senders", async () => { + const harness = createRuntimeHarness(); + setNostrRuntime(harness.runtime); + + const bus = { + sendDm: vi.fn(async () => {}), + close: vi.fn(), + getMetrics: vi.fn(() => ({ counters: {} })), + publishProfile: vi.fn(), + getProfileState: vi.fn(async () => null), + }; + mocks.startNostrBus.mockResolvedValueOnce(bus as never); + + const cleanup = (await nostrPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: buildResolvedNostrAccount({ + config: { dmPolicy: "pairing", allowFrom: [] }, + }), + }), + )) as { stop: () => void }; + + const options = mocks.startNostrBus.mock.calls[0]?.[0] as { + authorizeSender: (params: { + senderPubkey: string; + reply: (text: string) => Promise<void>; + }) => Promise<string>; + }; + const sendPairingReply = vi.fn(async (_text: string) => {}); + + await expect( + options.authorizeSender({ + senderPubkey: "nostr:UNKNOWN-SENDER", + reply: sendPairingReply, + }), + ).resolves.toBe("pairing"); + expect(sendPairingReply).toHaveBeenCalledTimes(1); + expect(sendPairingReply.mock.calls[0]?.[0]).toContain("Pairing code:"); + + cleanup.stop(); + }); + + it("routes allowed DMs through the standard reply pipeline", async () => { + const harness = createRuntimeHarness(); + setNostrRuntime(harness.runtime); + + const bus = { + sendDm: vi.fn(async () => {}), + close: vi.fn(), + getMetrics: vi.fn(() => ({ counters: {} })), + publishProfile: vi.fn(), + getProfileState: vi.fn(async () => null), + }; + mocks.startNostrBus.mockResolvedValueOnce(bus as never); + + const cleanup = (await nostrPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: buildResolvedNostrAccount({ + publicKey: "bot-pubkey", + config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, + }), + cfg: { + session: { store: { type: "jsonl" } }, + commands: { useAccessGroups: true }, + } as never, + }), + )) as { stop: () => void }; + + const options = mocks.startNostrBus.mock.calls[0]?.[0] as { + onMessage: ( + senderPubkey: string, + text: string, + reply: (text: string) => Promise<void>, + meta: { eventId: string; createdAt: number }, + ) => Promise<void>; + }; + const sendReply = vi.fn(async (_text: string) => {}); + + await options.onMessage("sender-pubkey", "hello from nostr", sendReply, { + eventId: "event-123", + createdAt: 1_710_000_000, + }); + + expect(harness.recordInboundSession).toHaveBeenCalledTimes(1); + expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + expect(harness.dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]?.ctx).toMatchObject({ + BodyForAgent: "hello from nostr", + SenderId: "sender-pubkey", + MessageSid: "event-123", + CommandAuthorized: true, + }); + expect(sendReply).toHaveBeenCalledWith("converted:|a|b|"); + + cleanup.stop(); + }); +});
extensions/nostr/src/channel.ts+217 −12 modified@@ -2,13 +2,20 @@ import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, } from "openclaw/plugin-sdk/extension-shared"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "openclaw/plugin-sdk/security-runtime"; import { buildChannelConfigSchema, + createChannelReplyPipeline, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, @@ -36,6 +43,60 @@ const activeBuses = new Map<string, NostrBusHandle>(); // Store metrics snapshots per account (for status reporting) const metricsSnapshots = new Map<string, MetricsSnapshot>(); +function normalizeNostrAllowEntry(entry: string): string | "*" | null { + const trimmed = entry.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + try { + return normalizePubkey(trimmed.replace(/^nostr:/i, "")); + } catch { + return null; + } +} + +function isNostrSenderAllowed(senderPubkey: string, allowFrom: string[]): boolean { + const normalizedSender = normalizePubkey(senderPubkey); + for (const entry of allowFrom) { + const normalized = normalizeNostrAllowEntry(entry); + if (normalized === "*") { + return true; + } + if (normalized === normalizedSender) { + return true; + } + } + return false; +} + +async function resolveNostrDirectAccess(params: { + accountId: string; + dmPolicy: "pairing" | "allowlist" | "open" | "disabled"; + allowFrom: Array<string | number> | undefined; + senderPubkey: string; +}) { + const storeAllowFrom = + params.dmPolicy === "pairing" + ? await readStoreAllowFromForDmPolicy({ + provider: "nostr", + accountId: params.accountId, + dmPolicy: params.dmPolicy, + }) + : []; + + return resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowEntries) => isNostrSenderAllowed(params.senderPubkey, allowEntries), + }); +} + const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({ channelKey: "nostr", resolvePolicy: (account) => account.config.dmPolicy, @@ -222,6 +283,11 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = { } const runtime = getNostrRuntime(); + const pairing = createChannelPairingController({ + core: runtime, + channel: "nostr", + accountId: account.accountId, + }); // Track bus handle for metrics callback let busHandle: NostrBusHandle | null = null; @@ -230,23 +296,162 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = { accountId: account.accountId, privateKey: account.privateKey, relays: account.relays, - onMessage: async (senderPubkey, text, reply) => { + authorizeSender: async ({ senderPubkey, reply }) => { + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const access = await resolveNostrDirectAccess({ + accountId: account.accountId, + dmPolicy, + allowFrom: account.config.allowFrom, + senderPubkey, + }); + if (access.decision === "allow") { + return "allow"; + } + if (access.decision === "pairing") { + await pairing.issueChallenge({ + senderId: senderPubkey, + senderIdLine: `Your Nostr pubkey: ${senderPubkey}`, + sendPairingReply: reply, + onCreated: () => { + ctx.log?.debug?.( + `[${account.accountId}] nostr pairing request sender=${senderPubkey}`, + ); + }, + onReplyError: (err) => { + ctx.log?.warn?.( + `[${account.accountId}] nostr pairing reply failed for ${senderPubkey}: ${String(err)}`, + ); + }, + }); + return "pairing"; + } ctx.log?.debug?.( - `[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`, + `[${account.accountId}] blocked Nostr sender ${senderPubkey} (${access.reason})`, ); + return "block"; + }, + onMessage: async (senderPubkey, text, reply, meta) => { + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const access = await resolveNostrDirectAccess({ + accountId: account.accountId, + dmPolicy, + allowFrom: account.config.allowFrom, + senderPubkey, + }); + if (access.decision !== "allow") { + ctx.log?.warn?.( + `[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${access.reason})`, + ); + return; + } - // Forward to OpenClaw's message pipeline - await ( - runtime.channel.reply as { handleInboundMessage?: (params: unknown) => Promise<void> } - ).handleInboundMessage?.({ + const shouldComputeAuth = runtime.channel.commands.shouldComputeCommandAuthorized( + text, + ctx.cfg, + ); + const senderAllowedForCommands = isNostrSenderAllowed( + senderPubkey, + access.effectiveAllowFrom, + ); + const commandAuthorized = shouldComputeAuth + ? dmPolicy === "open" + ? true + : resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups: ctx.cfg.commands?.useAccessGroups !== false, + authorizers: [ + { + configured: access.effectiveAllowFrom.length > 0, + allowed: senderAllowedForCommands, + }, + ], + modeWhenAccessGroupsOff: "configured", + }) + : undefined; + + const route = runtime.channel.routing.resolveAgentRoute({ + cfg: ctx.cfg, channel: "nostr", accountId: account.accountId, - senderId: senderPubkey, - chatType: "direct", - chatId: senderPubkey, // For DMs, chatId is the sender's pubkey - text, - reply: async (responseText: string) => { - await reply(responseText); + peer: { + kind: "direct", + id: senderPubkey, + }, + }); + const storePath = runtime.channel.session.resolveStorePath(ctx.cfg.session?.store, { + agentId: route.agentId, + }); + const body = runtime.channel.reply.formatAgentEnvelope({ + channel: "Nostr", + from: senderPubkey, + timestamp: meta.createdAt * 1000, + envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg), + body: text, + }); + const ctxPayload = runtime.channel.reply.finalizeInboundContext({ + Body: body, + BodyForAgent: text, + RawBody: text, + CommandBody: text, + From: `nostr:${senderPubkey}`, + To: `nostr:${account.publicKey}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: "direct", + ConversationLabel: senderPubkey, + SenderId: senderPubkey, + Provider: "nostr", + Surface: "nostr", + MessageSid: meta.eventId, + MessageSidFull: meta.eventId, + CommandAuthorized: commandAuthorized, + OriginatingChannel: "nostr", + OriginatingTo: `nostr:${account.publicKey}`, + }); + await runtime.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + ctx.log?.error?.( + `[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`, + ); + }, + }); + + const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + cfg: ctx.cfg, + agentId: route.agentId, + channel: "nostr", + accountId: route.accountId, + }); + await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: ctx.cfg, + dispatcherOptions: { + ...replyPipeline, + deliver: async (payload) => { + const outboundText = + payload && typeof payload === "object" && "text" in payload + ? String((payload as { text?: string }).text ?? "") + : ""; + if (!outboundText.trim()) { + return; + } + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg: ctx.cfg, + channel: "nostr", + accountId: route.accountId, + }); + await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode)); + }, + onError: (err, info) => { + ctx.log?.error?.( + `[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`, + ); + }, + }, + replyOptions: { + onModelSelected, }, }); },
extensions/nostr/src/nostr-bus.inbound.test.ts+194 −0 added@@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startNostrBus } from "./nostr-bus.js"; +import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js"; + +const BOT_PUBKEY = "b".repeat(64); + +const mockState = vi.hoisted(() => ({ + handlers: null as { + onevent: (event: Record<string, unknown>) => void | Promise<void>; + oneose?: () => void; + onclose?: (reason: string[]) => void; + } | null, + verifyEvent: vi.fn(() => true), + decrypt: vi.fn(() => "plaintext"), + publishProfile: vi.fn(async () => ({ + createdAt: 0, + eventId: "profile-event", + successes: [], + failures: [], + })), +})); + +vi.mock("nostr-tools", () => { + class MockSimplePool { + subscribeMany( + _relays: string[], + _filters: unknown, + handlers: { + onevent: (event: Record<string, unknown>) => void | Promise<void>; + oneose?: () => void; + onclose?: (reason: string[]) => void; + }, + ) { + mockState.handlers = handlers; + return { + close: vi.fn(), + }; + } + + publish = vi.fn(async () => {}); + } + + return { + SimplePool: MockSimplePool, + finalizeEvent: vi.fn((event: unknown) => event), + getPublicKey: vi.fn(() => BOT_PUBKEY), + verifyEvent: mockState.verifyEvent, + nip19: { + decode: vi.fn(), + npubEncode: vi.fn((value: string) => `npub-${value}`), + }, + }; +}); + +vi.mock("nostr-tools/nip04", () => ({ + decrypt: mockState.decrypt, + encrypt: vi.fn(() => "ciphertext"), +})); + +vi.mock("./nostr-state-store.js", () => ({ + readNostrBusState: vi.fn(async () => null), + writeNostrBusState: vi.fn(async () => {}), + computeSinceTimestamp: vi.fn(() => 0), + readNostrProfileState: vi.fn(async () => null), + writeNostrProfileState: vi.fn(async () => {}), +})); + +vi.mock("./nostr-profile.js", () => ({ + publishProfile: mockState.publishProfile, +})); + +function createEvent(overrides: Record<string, unknown> = {}) { + return { + id: "event-1", + kind: 4, + pubkey: "a".repeat(64), + content: "ciphertext", + created_at: Math.floor(Date.now() / 1000), + tags: [["p", BOT_PUBKEY]], + ...overrides, + }; +} + +async function emitEvent(event: Record<string, unknown>) { + if (!mockState.handlers) { + throw new Error("missing subscription handlers"); + } + await mockState.handlers.onevent(event); +} + +describe("startNostrBus inbound guards", () => { + beforeEach(() => { + mockState.handlers = null; + mockState.verifyEvent.mockClear(); + mockState.verifyEvent.mockReturnValue(true); + mockState.decrypt.mockClear(); + mockState.decrypt.mockReturnValue("plaintext"); + }); + + afterEach(() => { + mockState.handlers = null; + }); + + it("checks sender authorization before verify/decrypt", async () => { + const onMessage = vi.fn(async () => {}); + const authorizeSender = vi.fn(async () => "block" as const); + const bus = await startNostrBus({ + privateKey: TEST_HEX_PRIVATE_KEY, + onMessage, + authorizeSender, + onMetric: () => {}, + }); + + await emitEvent(createEvent()); + + expect(authorizeSender).toHaveBeenCalledTimes(1); + expect(mockState.verifyEvent).not.toHaveBeenCalled(); + expect(mockState.decrypt).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + expect(bus.getMetrics().eventsReceived).toBe(1); + + bus.close(); + }); + + it("rate limits repeated events before decrypt", async () => { + const onMessage = vi.fn(async () => {}); + const bus = await startNostrBus({ + privateKey: TEST_HEX_PRIVATE_KEY, + onMessage, + onMetric: () => {}, + }); + + for (let i = 0; i < 21; i += 1) { + await emitEvent( + createEvent({ + id: `event-${i}`, + }), + ); + } + + const snapshot = bus.getMetrics(); + expect(snapshot.eventsRejected.rateLimited).toBe(1); + expect(mockState.decrypt).toHaveBeenCalledTimes(20); + expect(onMessage).toHaveBeenCalledTimes(20); + + bus.close(); + }); + + it("rejects far-future events before crypto", async () => { + const onMessage = vi.fn(async () => {}); + const bus = await startNostrBus({ + privateKey: TEST_HEX_PRIVATE_KEY, + onMessage, + onMetric: () => {}, + }); + + await emitEvent( + createEvent({ + created_at: Math.floor(Date.now() / 1000) + 600, + }), + ); + + const snapshot = bus.getMetrics(); + expect(snapshot.eventsRejected.future).toBe(1); + expect(mockState.verifyEvent).not.toHaveBeenCalled(); + expect(mockState.decrypt).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + + bus.close(); + }); + + it("rejects oversized ciphertext before verify/decrypt", async () => { + const onMessage = vi.fn(async () => {}); + const bus = await startNostrBus({ + privateKey: TEST_HEX_PRIVATE_KEY, + onMessage, + onMetric: () => {}, + }); + + await emitEvent( + createEvent({ + content: "x".repeat(20_000), + }), + ); + + const snapshot = bus.getMetrics(); + expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1); + expect(mockState.verifyEvent).not.toHaveBeenCalled(); + expect(mockState.decrypt).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + + bus.close(); + }); +});
extensions/nostr/src/nostr-bus.ts+151 −15 modified@@ -33,6 +33,13 @@ import { createSeenTracker, type SeenTracker } from "./seen-tracker.js"; const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew const MAX_PERSISTED_EVENT_IDS = 5000; const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes +const MAX_EVENT_FUTURE_SKEW_SEC = 120; +const MAX_CIPHERTEXT_BYTES = 16 * 1024; +const MAX_PLAINTEXT_BYTES = 8 * 1024; +const RATE_LIMIT_WINDOW_MS = 60_000; +const MAX_EVENTS_PER_SENDER_PER_WINDOW = 20; +const MAX_EVENTS_GLOBAL_PER_WINDOW = 200; +const MAX_TRACKED_RATE_LIMIT_KEYS = 4096; // Circuit breaker configuration const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening @@ -57,7 +64,13 @@ export interface NostrBusOptions { pubkey: string, text: string, reply: (text: string) => Promise<void>, + meta: { eventId: string; createdAt: number }, ) => Promise<void>; + /** Called before expensive crypto to allow sender policy checks (optional) */ + authorizeSender?: (params: { + senderPubkey: string; + reply: (text: string) => Promise<void>; + }) => Promise<"allow" | "block" | "pairing">; /** Called on errors (optional) */ onError?: (error: Error, context: string) => void; /** Called on connection status changes (optional) */ @@ -74,6 +87,62 @@ export interface NostrBusOptions { seenTtlMs?: number; } +type FixedWindowRateLimiter = { + isRateLimited: (key: string, nowMs?: number) => boolean; + size: () => number; + clear: () => void; +}; + +function createFixedWindowRateLimiter(params: { + windowMs: number; + maxRequests: number; + maxTrackedKeys: number; +}): FixedWindowRateLimiter { + const windowMs = Math.max(1, Math.floor(params.windowMs)); + const maxRequests = Math.max(1, Math.floor(params.maxRequests)); + const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys)); + const state = new Map<string, { count: number; windowStartMs: number }>(); + + const touch = (key: string, value: { count: number; windowStartMs: number }) => { + state.delete(key); + state.set(key, value); + }; + + const prune = (nowMs: number) => { + for (const [key, entry] of state) { + if (nowMs - entry.windowStartMs >= windowMs) { + state.delete(key); + } + } + while (state.size > maxTrackedKeys) { + const oldest = state.keys().next().value; + if (!oldest) { + break; + } + state.delete(oldest); + } + }; + + return { + isRateLimited: (key: string, nowMs = Date.now()) => { + if (!key) { + return false; + } + prune(nowMs); + const existing = state.get(key); + if (!existing || nowMs - existing.windowStartMs >= windowMs) { + touch(key, { count: 1, windowStartMs: nowMs }); + return false; + } + const nextCount = existing.count + 1; + touch(key, { count: nextCount, windowStartMs: existing.windowStartMs }); + return nextCount > maxRequests; + }, + size: () => state.size, + clear: () => state.clear(), + }; +} + export interface NostrBusHandle { /** Stop the bus and close connections */ close: () => void; @@ -322,6 +391,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH privateKey, relays = DEFAULT_RELAYS, onMessage, + authorizeSender, onError, onEose, onMetric, @@ -396,6 +466,23 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH } const inflight = new Set<string>(); + const perSenderRateLimiter = createFixedWindowRateLimiter({ + windowMs: RATE_LIMIT_WINDOW_MS, + maxRequests: MAX_EVENTS_PER_SENDER_PER_WINDOW, + maxTrackedKeys: MAX_TRACKED_RATE_LIMIT_KEYS, + }); + const globalRateLimiter = createFixedWindowRateLimiter({ + windowMs: RATE_LIMIT_WINDOW_MS, + maxRequests: MAX_EVENTS_GLOBAL_PER_WINDOW, + maxTrackedKeys: 1, + }); + + const updateRateLimiterSizeMetric = () => { + metrics.emit( + "memory.rate_limiter_entries", + perSenderRateLimiter.size() + globalRateLimiter.size(), + ); + }; // Event handler async function handleEvent(event: Event): Promise<void> { @@ -421,6 +508,16 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH return; } + if (event.created_at > Math.floor(Date.now() / 1000) + MAX_EVENT_FUTURE_SKEW_SEC) { + metrics.emit("event.rejected.future"); + return; + } + + if (event.kind !== 4) { + metrics.emit("event.rejected.wrong_kind"); + return; + } + // Fast p-tag check BEFORE crypto (no allocation, cheaper) let targetsUs = false; for (const t of event.tags) { @@ -434,6 +531,50 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH return; } + const replyTo = async (text: string): Promise<void> => { + await sendEncryptedDm( + pool, + sk, + event.pubkey, + text, + relays, + metrics, + circuitBreakers, + healthTracker, + onError, + ); + }; + + if (authorizeSender) { + const decision = await authorizeSender({ + senderPubkey: event.pubkey, + reply: replyTo, + }); + if (decision !== "allow") { + return; + } + } + + updateRateLimiterSizeMetric(); + if (globalRateLimiter.isRateLimited("global")) { + metrics.emit("rate_limit.global"); + metrics.emit("event.rejected.rate_limited"); + updateRateLimiterSizeMetric(); + return; + } + if (perSenderRateLimiter.isRateLimited(event.pubkey)) { + metrics.emit("rate_limit.per_sender"); + metrics.emit("event.rejected.rate_limited"); + updateRateLimiterSizeMetric(); + return; + } + updateRateLimiterSizeMetric(); + + if (Buffer.byteLength(event.content, "utf8") > MAX_CIPHERTEXT_BYTES) { + metrics.emit("event.rejected.oversized_ciphertext"); + return; + } + // Verify signature (must pass before we trust the event) if (!verifyEvent(event)) { metrics.emit("event.rejected.invalid_signature"); @@ -457,23 +598,16 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH return; } - // Create reply function (try relays by health score) - const replyTo = async (text: string): Promise<void> => { - await sendEncryptedDm( - pool, - sk, - event.pubkey, - text, - relays, - metrics, - circuitBreakers, - healthTracker, - onError, - ); - }; + if (Buffer.byteLength(plaintext, "utf8") > MAX_PLAINTEXT_BYTES) { + metrics.emit("event.rejected.oversized_plaintext"); + return; + } // Call the message handler - await onMessage(event.pubkey, plaintext, replyTo); + await onMessage(event.pubkey, plaintext, replyTo, { + eventId: event.id, + createdAt: event.created_at, + }); // Mark as processed metrics.emit("event.processed"); @@ -568,6 +702,8 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH close: () => { sub.close(); seen.stop(); + perSenderRateLimiter.clear(); + globalRateLimiter.clear(); // Flush pending state write synchronously on close if (pendingWrite) { clearTimeout(pendingWrite);
src/plugin-sdk/nostr.ts+1 −0 modified@@ -7,6 +7,7 @@ export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js";
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/1ee9611079e81b9122f4bed01abb3d9f56206c77nvdPatchWEB
- github.com/openclaw/openclaw/commit/630f1479c44f78484dfa21bb407cbe6f171dac87nvdPatchWEB
- github.com/advisories/GHSA-65h8-27jh-q8wvghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-65h8-27jh-q8wvnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35627ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-unauthenticated-cryptographic-work-in-nostr-inbound-dm-handlingnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.