mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 03:21:57 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user