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 <bradgroux@users.noreply.github.com>
This commit is contained in:
Lewis
2026-04-06 15:52:56 +01:00
committed by GitHub
parent c921a6ecad
commit 1234c873bc
3 changed files with 477 additions and 25 deletions

View File

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

View File

@@ -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();
});
});

View File

@@ -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<void> {
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<void> {
await validateConsentUploadUrl(params.url, params.validationOpts);
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
method: "PUT",