From 1234c873bcd22e3f07d1f5c8ee3beb28bcd2c3fe Mon Sep 17 00:00:00 2001 From: Lewis <58551848+lewiswigmore@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:52:56 +0100 Subject: [PATCH] fix(msteams): add SSRF validation to file consent upload URL (#23596) * fix(msteams): add SSRF validation to file consent upload URL The uploadToConsentUrl() function previously accepted any URL from the fileConsent/invoke response without validation. A malicious Teams tenant user could craft an invoke activity with an attacker-controlled uploadUrl, causing the bot to PUT file data to arbitrary destinations (SSRF). This commit adds validateConsentUploadUrl() which enforces: 1. HTTPS-only protocol 2. Hostname must match a strict allowlist of Microsoft/SharePoint domains (sharepoint.com, graph.microsoft.com, onedrive.com, etc.) 3. DNS resolution check rejects private/reserved IPs (RFC 1918, loopback, link-local) to prevent DNS rebinding attacks The CONSENT_UPLOAD_HOST_ALLOWLIST is intentionally narrower than the existing DEFAULT_MEDIA_HOST_ALLOWLIST, excluding overly broad domains like blob.core.windows.net and trafficmanager.net that any Azure customer can create endpoints under. Includes 47 tests covering IPv4/IPv6 private IP detection, protocol enforcement, hostname allowlist matching, DNS failure handling, and end-to-end upload validation. * fix(msteams): validate all DNS answers for consent uploads * fix(msteams): restore changelog header --------- Co-authored-by: Brad Groux --- extensions/msteams/CHANGELOG.md | 23 +- extensions/msteams/src/file-consent.test.ts | 343 +++++++++++++++++++- extensions/msteams/src/file-consent.ts | 136 ++++++++ 3 files changed, 477 insertions(+), 25 deletions(-) diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 09efb94cc29..a09e97cbf3e 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Version alignment with core OpenClaw release numbers. +- Harden file consent upload URL DNS validation by checking all resolved addresses before upload. ## 2026.4.4 @@ -119,25 +120,3 @@ ### Changes - Version alignment with core OpenClaw release numbers. - -## 2026.2.22 - -### Changes - -- Version alignment with core OpenClaw release numbers. - -## 2026.1.15 - -### Features - -- Bot Framework gateway monitor (Express + JWT auth) with configurable webhook path/port and `/api/messages` fallback. -- Onboarding flow for Azure Bot credentials (config + env var detection) and DM policy setup. -- Channel capabilities: DMs, group chats, channels, threads, media, polls, and `teams` alias. -- DM pairing/allowlist enforcement plus group policies with per-team/channel overrides and mention gating. -- Inbound debounce + history context for room/group chats; mention tag stripping and timestamp parsing. -- Proactive messaging via stored conversation references (file store with TTL/size pruning). -- Outbound text/media send with markdown chunking, 4k limit, split/inline media handling. -- Adaptive Card polls: build cards, parse votes, and persist poll state with vote tracking. -- Attachment processing: placeholders + HTML summaries, inline image extraction (including data: URLs). -- Media downloads with host allowlist, auth scope fallback, and Graph hostedContents/attachments fallback. -- Retry/backoff on transient/throttled sends with classified errors + helpful hints. diff --git a/extensions/msteams/src/file-consent.test.ts b/extensions/msteams/src/file-consent.test.ts index bf1f84f8608..0a3d641c524 100644 --- a/extensions/msteams/src/file-consent.test.ts +++ b/extensions/msteams/src/file-consent.test.ts @@ -1,18 +1,277 @@ import { describe, expect, it, vi } from "vitest"; -import { uploadToConsentUrl } from "./file-consent.js"; +import { + CONSENT_UPLOAD_HOST_ALLOWLIST, + isPrivateOrReservedIP, + uploadToConsentUrl, + validateConsentUploadUrl, +} from "./file-consent.js"; + +// Helper: a resolveFn that returns a public IP by default +const publicResolve = async () => ({ address: "13.107.136.10" }); +// Helper: a resolveFn that returns a private IP +const privateResolve = (ip: string) => async () => ({ address: ip }); +// Helper: a resolveFn that returns multiple addresses +const multiResolve = (ips: string[]) => async () => ips.map((address) => ({ address })); +// Helper: a resolveFn that fails +const failingResolve = async () => { + throw new Error("DNS failure"); +}; + +// ─── isPrivateOrReservedIP ─────────────────────────────────────────────────── + +describe("isPrivateOrReservedIP", () => { + it.each([ + ["10.0.0.1", true], + ["10.255.255.255", true], + ["172.16.0.1", true], + ["172.31.255.255", true], + ["172.15.0.1", false], + ["172.32.0.1", false], + ["192.168.0.1", true], + ["192.168.255.255", true], + ["127.0.0.1", true], + ["127.255.255.255", true], + ["169.254.0.1", true], + ["169.254.169.254", true], + ["0.0.0.0", true], + ["8.8.8.8", false], + ["13.107.136.10", false], + ["52.96.0.1", false], + ] as const)("IPv4 %s → %s", (ip, expected) => { + expect(isPrivateOrReservedIP(ip)).toBe(expected); + }); + + it.each([ + ["::1", true], + ["::", true], + ["fe80::1", true], + ["fe80::", true], + ["fc00::1", true], + ["fd12:3456::1", true], + ["2001:0db8::1", false], + ["2620:1ec:c11::200", false], + // IPv4-mapped IPv6 addresses + ["::ffff:127.0.0.1", true], + ["::ffff:10.0.0.1", true], + ["::ffff:192.168.1.1", true], + ["::ffff:169.254.169.254", true], + ["::ffff:8.8.8.8", false], + ["::ffff:13.107.136.10", false], + ] as const)("IPv6 %s → %s", (ip, expected) => { + expect(isPrivateOrReservedIP(ip)).toBe(expected); + }); + + it.each([ + ["999.999.999.999", false], + ["256.0.0.1", false], + ["10.0.0.256", false], + ["-1.0.0.1", false], + ["1.2.3.4.5", false], + ] as const)("malformed IPv4 %s → %s", (ip, expected) => { + expect(isPrivateOrReservedIP(ip)).toBe(expected); + }); +}); + +// ─── validateConsentUploadUrl ──────────────────────────────────────────────── + +describe("validateConsentUploadUrl", () => { + it("accepts a valid SharePoint HTTPS URL", async () => { + await expect( + validateConsentUploadUrl("https://contoso.sharepoint.com/sites/uploads/file.pdf", { + resolveFn: publicResolve, + }), + ).resolves.toBeUndefined(); + }); + + it("accepts subdomains of allowlisted domains", async () => { + await expect( + validateConsentUploadUrl( + "https://contoso-my.sharepoint.com/personal/user/Documents/file.docx", + { resolveFn: publicResolve }, + ), + ).resolves.toBeUndefined(); + }); + + it("accepts graph.microsoft.com", async () => { + await expect( + validateConsentUploadUrl("https://graph.microsoft.com/v1.0/me/drive/items/123/content", { + resolveFn: publicResolve, + }), + ).resolves.toBeUndefined(); + }); + + it("rejects non-HTTPS URLs", async () => { + await expect( + validateConsentUploadUrl("http://contoso.sharepoint.com/file.pdf", { + resolveFn: publicResolve, + }), + ).rejects.toThrow("must use HTTPS"); + }); + + it("rejects invalid URLs", async () => { + await expect( + validateConsentUploadUrl("not a url", { resolveFn: publicResolve }), + ).rejects.toThrow("not a valid URL"); + }); + + it("rejects hosts not in the allowlist", async () => { + await expect( + validateConsentUploadUrl("https://evil.example.com/exfil", { resolveFn: publicResolve }), + ).rejects.toThrow("not in the allowed domains"); + }); + + it("rejects an SSRF attempt with internal metadata URL", async () => { + await expect( + validateConsentUploadUrl("https://169.254.169.254/latest/meta-data/", { + resolveFn: publicResolve, + }), + ).rejects.toThrow("not in the allowed domains"); + }); + + it("rejects localhost", async () => { + await expect( + validateConsentUploadUrl("https://localhost:8080/internal", { resolveFn: publicResolve }), + ).rejects.toThrow("not in the allowed domains"); + }); + + it("rejects when DNS resolves to a private IPv4 (10.x)", async () => { + await expect( + validateConsentUploadUrl("https://malicious.sharepoint.com/exfil", { + resolveFn: privateResolve("10.0.0.1"), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects when DNS resolves to loopback", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: privateResolve("127.0.0.1"), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects when DNS resolves to link-local (169.254.x.x)", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: privateResolve("169.254.169.254"), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects when DNS resolves to IPv6 loopback", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: privateResolve("::1"), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects when DNS resolves to IPv4-mapped IPv6 private address", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: privateResolve("::ffff:10.0.0.1"), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects when DNS resolves to IPv4-mapped IPv6 loopback", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: privateResolve("::ffff:127.0.0.1"), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects when any DNS answer is private/reserved", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: multiResolve(["13.107.136.10", "10.0.0.1"]), + }), + ).rejects.toThrow("private/reserved IP"); + }); + + it("accepts when all DNS answers are public", async () => { + await expect( + validateConsentUploadUrl("https://evil.sharepoint.com/path", { + resolveFn: multiResolve(["13.107.136.10", "52.96.0.1"]), + }), + ).resolves.toBeUndefined(); + }); + + it("rejects when DNS resolution fails", async () => { + await expect( + validateConsentUploadUrl("https://nonexistent.sharepoint.com/path", { + resolveFn: failingResolve, + }), + ).rejects.toThrow("Failed to resolve"); + }); + + it("accepts a custom allowlist", async () => { + await expect( + validateConsentUploadUrl("https://custom.example.org/file", { + allowlist: ["example.org"], + resolveFn: publicResolve, + }), + ).resolves.toBeUndefined(); + }); + + it("rejects hosts that are suffix-tricked (e.g. notsharepoint.com)", async () => { + await expect( + validateConsentUploadUrl("https://notsharepoint.com/file", { resolveFn: publicResolve }), + ).rejects.toThrow("not in the allowed domains"); + }); + + it("rejects file:// protocol", async () => { + await expect( + validateConsentUploadUrl("file:///etc/passwd", { resolveFn: publicResolve }), + ).rejects.toThrow("must use HTTPS"); + }); +}); + +// ─── CONSENT_UPLOAD_HOST_ALLOWLIST ─────────────────────────────────────────── + +describe("CONSENT_UPLOAD_HOST_ALLOWLIST", () => { + it("contains only Microsoft/SharePoint domains", () => { + for (const domain of CONSENT_UPLOAD_HOST_ALLOWLIST) { + expect( + domain.includes("microsoft") || + domain.includes("sharepoint") || + domain.includes("onedrive") || + domain.includes("1drv") || + domain.includes("live.com"), + ).toBe(true); + } + }); + + it("does not contain overly broad domains", () => { + const broad = [ + "microsoft.com", + "azure.com", + "blob.core.windows.net", + "azureedge.net", + "trafficmanager.net", + ]; + for (const domain of broad) { + expect(CONSENT_UPLOAD_HOST_ALLOWLIST).not.toContain(domain); + } + }); +}); + +// ─── uploadToConsentUrl (integration with validation) ──────────────────────── describe("uploadToConsentUrl", () => { it("sends the OpenClaw User-Agent header with consent uploads", async () => { const fetchFn = vi.fn(async () => new Response(null, { status: 200 })); await uploadToConsentUrl({ - url: "https://upload.example.com/file", + url: "https://contoso.sharepoint.com/upload", buffer: Buffer.from("hello"), fetchFn, + validationOpts: { resolveFn: publicResolve }, }); expect(fetchFn).toHaveBeenCalledWith( - "https://upload.example.com/file", + "https://contoso.sharepoint.com/upload", expect.objectContaining({ method: "PUT", headers: expect.objectContaining({ @@ -23,4 +282,82 @@ describe("uploadToConsentUrl", () => { }), ); }); + + it("blocks upload to a disallowed host", async () => { + const mockFetch = vi.fn(); + await expect( + uploadToConsentUrl({ + url: "https://evil.example.com/exfil", + buffer: Buffer.from("secret data"), + fetchFn: mockFetch, + validationOpts: { resolveFn: publicResolve }, + }), + ).rejects.toThrow("not in the allowed domains"); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("blocks upload to a private IP", async () => { + const mockFetch = vi.fn(); + await expect( + uploadToConsentUrl({ + url: "https://compromised.sharepoint.com/upload", + buffer: Buffer.from("data"), + fetchFn: mockFetch, + validationOpts: { resolveFn: privateResolve("10.0.0.1") }, + }), + ).rejects.toThrow("private/reserved IP"); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("allows upload to a valid SharePoint URL and performs PUT", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const buffer = Buffer.from("file content"); + + await uploadToConsentUrl({ + url: "https://contoso.sharepoint.com/sites/uploads/file.pdf", + buffer, + contentType: "application/pdf", + fetchFn: mockFetch, + validationOpts: { resolveFn: publicResolve }, + }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe("https://contoso.sharepoint.com/sites/uploads/file.pdf"); + expect(opts.method).toBe("PUT"); + expect(opts.headers["Content-Type"]).toBe("application/pdf"); + }); + + it("throws on non-OK response after passing validation", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + }); + + await expect( + uploadToConsentUrl({ + url: "https://contoso.sharepoint.com/sites/uploads/file.pdf", + buffer: Buffer.from("data"), + fetchFn: mockFetch, + validationOpts: { resolveFn: publicResolve }, + }), + ).rejects.toThrow("File upload to consent URL failed: 403 Forbidden"); + }); + + it("blocks HTTP (non-HTTPS) upload before fetch is called", async () => { + const mockFetch = vi.fn(); + await expect( + uploadToConsentUrl({ + url: "http://contoso.sharepoint.com/upload", + buffer: Buffer.from("data"), + fetchFn: mockFetch, + validationOpts: { resolveFn: publicResolve }, + }), + ).rejects.toThrow("must use HTTPS"); + + expect(mockFetch).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/msteams/src/file-consent.ts b/extensions/msteams/src/file-consent.ts index 8f79595f599..ba1e5f31922 100644 --- a/extensions/msteams/src/file-consent.ts +++ b/extensions/msteams/src/file-consent.ts @@ -8,8 +8,135 @@ * - Parsing fileConsent/invoke activities */ +import { lookup } from "node:dns/promises"; import { buildUserAgent } from "./user-agent.js"; +/** + * Allowlist of domains that are valid targets for file consent uploads. + * These are the Microsoft/SharePoint domains that Teams legitimately provides + * as upload destinations in the FileConsentCard flow. + */ +export const CONSENT_UPLOAD_HOST_ALLOWLIST = [ + "sharepoint.com", + "sharepoint.us", + "sharepoint.de", + "sharepoint.cn", + "sharepoint-df.com", + "storage.live.com", + "onedrive.com", + "1drv.ms", + "graph.microsoft.com", + "graph.microsoft.us", + "graph.microsoft.de", + "graph.microsoft.cn", +] as const; + +/** + * Returns true if the given IPv4 or IPv6 address is in a private, loopback, + * or link-local range that must never be reached via consent uploads. + */ +export function isPrivateOrReservedIP(ip: string): boolean { + // Handle IPv4-mapped IPv6 first (e.g., ::ffff:127.0.0.1, ::ffff:10.0.0.1) + const ipv4MappedMatch = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip); + if (ipv4MappedMatch) { + return isPrivateOrReservedIP(ipv4MappedMatch[1]); + } + + // IPv4 checks + const v4Parts = ip.split("."); + if (v4Parts.length === 4) { + const octets = v4Parts.map(Number); + // Validate all octets are integers in 0-255 + if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + return false; + } + const [a, b] = octets; + // 10.0.0.0/8 + if (a === 10) return true; + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + // 127.0.0.0/8 (loopback) + if (a === 127) return true; + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) return true; + // 0.0.0.0/8 + if (a === 0) return true; + } + + // IPv6 checks + const normalized = ip.toLowerCase(); + // ::1 loopback + if (normalized === "::1") return true; + // fe80::/10 link-local + if (normalized.startsWith("fe80:") || normalized.startsWith("fe80")) return true; + // fc00::/7 unique-local (fc00:: and fd00::) + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; + // :: unspecified + if (normalized === "::") return true; + + return false; +} + +/** + * Validate that a consent upload URL is safe to PUT to. + * Checks: + * 1. Protocol is HTTPS + * 2. Hostname matches the consent upload allowlist + * 3. Resolved IP is not in a private/reserved range (anti-SSRF) + * + * @throws Error if the URL fails validation + */ +export async function validateConsentUploadUrl( + url: string, + opts?: { + allowlist?: readonly string[]; + resolveFn?: (hostname: string) => Promise<{ address: string } | { address: string }[]>; + }, +): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error("Consent upload URL is not a valid URL"); + } + + // 1. Protocol check + if (parsed.protocol !== "https:") { + throw new Error(`Consent upload URL must use HTTPS, got ${parsed.protocol}`); + } + + // 2. Hostname allowlist check + const hostname = parsed.hostname.toLowerCase(); + const allowlist = opts?.allowlist ?? CONSENT_UPLOAD_HOST_ALLOWLIST; + const hostAllowed = allowlist.some( + (entry) => hostname === entry || hostname.endsWith(`.${entry}`), + ); + if (!hostAllowed) { + throw new Error(`Consent upload URL hostname "${hostname}" is not in the allowed domains`); + } + + // 3. DNS resolution — reject private/reserved IPs. + // Check all resolved addresses to avoid SSRF bypass via mixed public/private answers. + const resolveFn = opts?.resolveFn ?? ((name: string) => lookup(name, { all: true })); + let resolved: { address: string }[]; + try { + const result = await resolveFn(hostname); + resolved = Array.isArray(result) ? result : [result]; + } catch { + throw new Error(`Failed to resolve consent upload URL hostname "${hostname}"`); + } + + for (const entry of resolved) { + if (isPrivateOrReservedIP(entry.address)) { + throw new Error( + `Consent upload URL resolves to a private/reserved IP (${entry.address})`, + ); + } + } +} + export interface FileConsentCardParams { filename: string; description?: string; @@ -105,13 +232,22 @@ export function parseFileConsentInvoke(activity: { /** * Upload a file to the consent URL provided by Teams. * The URL is provided in the fileConsent/invoke response after user accepts. + * + * @throws Error if the URL fails SSRF validation (non-HTTPS, disallowed host, private IP) */ export async function uploadToConsentUrl(params: { url: string; buffer: Buffer; contentType?: string; fetchFn?: typeof fetch; + /** Override for testing — custom allowlist and DNS resolver */ + validationOpts?: { + allowlist?: readonly string[]; + resolveFn?: (hostname: string) => Promise<{ address: string } | { address: string }[]>; + }; }): Promise { + await validateConsentUploadUrl(params.url, params.validationOpts); + const fetchFn = params.fetchFn ?? fetch; const res = await fetchFn(params.url, { method: "PUT",