VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.222026.3.22

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.22

Patches

2
630f1479c44f

build: prepare 2026.3.23-2

https://github.com/openclaw/openclawPeter SteinbergerMar 24, 2026via ghsa
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"}
    
1ee9611079e8

fix(nostr): enforce inbound dm policy before decrypt

https://github.com/openclaw/openclawPeter SteinbergerMar 22, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.