Skip to content

fix(msteams): add SSRF validation to file consent upload URL#23596

Open
lewiswigmore wants to merge 2 commits intoopenclaw:mainfrom
lewiswigmore:security/file-consent-ssrf-validation
Open

fix(msteams): add SSRF validation to file consent upload URL#23596
lewiswigmore wants to merge 2 commits intoopenclaw:mainfrom
lewiswigmore:security/file-consent-ssrf-validation

Conversation

@lewiswigmore
Copy link
Copy Markdown
Contributor

@lewiswigmore lewiswigmore commented Feb 22, 2026

Summary

uploadToConsentUrl() in the MS Teams plugin performs a PUT request to the URL provided in the fileConsent/invoke response without any validation. While the invoke activity is authenticated via JWT (Bot Framework webhook auth), a malicious user within an authenticated Teams tenant can craft an invoke with an attacker-controlled uploadUrl, causing the bot to PUT file data to arbitrary destinations — a server-side request forgery (SSRF).

Attack scenario

  1. Attacker sends a message triggering a large-file upload flow
  2. Bot sends a FileConsentCard with a pending upload stored in memory
  3. Attacker crafts a fileConsent/invoke activity with action: "accept" and uploadInfo.uploadUrl pointing to an internal service (e.g. http://169.254.169.254/latest/meta-data/ or an internal API)
  4. Bot performs a PUT request to the attacker-chosen URL with the file buffer as the body

Fix

This PR adds validateConsentUploadUrl() which enforces three checks before any fetch occurs:

  1. HTTPS-only — rejects http://, file://, and other protocols
  2. Hostname allowlist — a new CONSENT_UPLOAD_HOST_ALLOWLIST restricted to Microsoft/SharePoint domains that Teams legitimately provides as upload destinations (sharepoint.com, graph.microsoft.com, onedrive.com, etc.)
  3. DNS resolution check — resolves the hostname and rejects private/reserved IPs (RFC 1918, loopback 127.0.0.0/8, link-local 169.254.0.0/16, IPv6 ::1, fe80::/10, fc00::/7) to prevent DNS rebinding attacks

The allowlist is intentionally narrower than DEFAULT_MEDIA_HOST_ALLOWLIST, which includes overly broad domains like blob.core.windows.net and trafficmanager.net that any Azure customer can create endpoints under.

Testing

47 new tests covering:

  • IPv4/IPv6 private IP detection (24 cases)
  • Protocol enforcement, hostname allowlist matching, suffix-trick rejection
  • DNS failure handling
  • End-to-end uploadToConsentUrl() integration verifying fetch is never called for blocked URLs

All 201 existing msteams tests continue to pass with zero regressions.

Greptile Summary

Adds SSRF protection to MS Teams file consent upload flow by validating URLs before fetch with HTTPS enforcement, Microsoft/SharePoint domain allowlist, and DNS resolution checks to block private IPs. The fix addresses a real security vulnerability where authenticated Teams users could trigger bot PUT requests to arbitrary internal endpoints.

Key changes:

  • New validateConsentUploadUrl() enforces protocol, hostname allowlist, and DNS-based IP checks
  • CONSENT_UPLOAD_HOST_ALLOWLIST restricts uploads to legitimate Microsoft domains
  • isPrivateOrReservedIP() blocks RFC 1918, loopback, and link-local ranges
  • 47 new tests with comprehensive coverage of validation logic
  • Integration point in uploadToConsentUrl() ensures all uploads are validated

Critical issues found:

  • IPv6 validation has gaps: IPv4-mapped addresses like ::ffff:10.0.0.1 bypass private IP detection
  • IPv4 validation doesn't verify octets are in 0-255 range, allowing malformed IPs through

Both issues could allow SSRF attacks to succeed despite the validation layer.

Confidence Score: 2/5

  • This PR has critical security gaps in IPv6 and IPv4 validation that could allow SSRF bypass
  • Score reflects two confirmed logical errors in the IP validation layer that directly undermine the SSRF protection: (1) IPv4-mapped IPv6 addresses can bypass private IP detection, and (2) invalid IPv4 octets aren't validated. Both issues create attack vectors that defeat the security fix
  • Pay close attention to extensions/msteams/src/file-consent.ts - the IP validation logic needs fixes before merge

Last reviewed commit: f260f9a

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

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.
@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: M labels Feb 22, 2026
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +57 to +67
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPv6 detection incomplete - doesn't handle IPv4-embedded addresses like ::ffff:127.0.0.1 which bypass validation

Suggested change
// 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;
// IPv6 checks
const normalized = ip.toLowerCase();
// Handle IPv4-embedded IPv6 (e.g., ::ffff:127.0.0.1, ::ffff:10.0.0.1)
const ipv4MappedMatch = /::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
if (ipv4MappedMatch) {
return isPrivateOrReservedIP(ipv4MappedMatch[1]);
}
// ::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;
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/file-consent.ts
Line: 57-67

Comment:
IPv6 detection incomplete - doesn't handle IPv4-embedded addresses like `::ffff:127.0.0.1` which bypass validation

```suggestion
  // IPv6 checks
  const normalized = ip.toLowerCase();
  
  // Handle IPv4-embedded IPv6 (e.g., ::ffff:127.0.0.1, ::ffff:10.0.0.1)
  const ipv4MappedMatch = /::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
  if (ipv4MappedMatch) {
    return isPrivateOrReservedIP(ipv4MappedMatch[1]);
  }
  
  // ::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;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +38 to +55
// IPv4 checks
const v4Parts = ip.split(".");
if (v4Parts.length === 4) {
const a = Number(v4Parts[0]);
const b = Number(v4Parts[1]);
// 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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPv4 validation doesn't verify octets are 0-255, allowing invalid IPs like 999.999.999.999 to pass through

Suggested change
// IPv4 checks
const v4Parts = ip.split(".");
if (v4Parts.length === 4) {
const a = Number(v4Parts[0]);
const b = Number(v4Parts[1]);
// 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;
}
// IPv4 checks
const v4Parts = ip.split(".");
if (v4Parts.length === 4) {
const octets = v4Parts.map(Number);
// Validate all parts are valid 0-255
if (octets.some((n) => Number.isNaN(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;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/file-consent.ts
Line: 38-55

Comment:
IPv4 validation doesn't verify octets are 0-255, allowing invalid IPs like `999.999.999.999` to pass through

```suggestion
  // IPv4 checks
  const v4Parts = ip.split(".");
  if (v4Parts.length === 4) {
    const octets = v4Parts.map(Number);
    // Validate all parts are valid 0-255
    if (octets.some((n) => Number.isNaN(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;
  }
```

How can I resolve this? If you propose a fix, please make it concise.

…ateOrReservedIP

Address review feedback:
- Detect IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) and recursively
  check the embedded IPv4 against private/reserved ranges
- Validate IPv4 octets are integers in 0-255, rejecting malformed IPs
  like 999.999.999.999 or 256.0.0.1
- Add 11 new test cases covering both fixes
@openclaw-barnacle
Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle bot added the stale Marked as stale due to inactivity label Feb 28, 2026
@lewiswigmore
Copy link
Copy Markdown
Contributor Author

The failing CI check (checks-windows) is unrelated to this PR — it's 3 tests in test/media-understanding.auto.test.ts (sherpa-onnx, whisper-cli, gemini CLI) that appear to be broken on the Windows runner independently. All 4804 other tests pass, including the 47 new file-consent tests.

Can a maintainer re-run or skip the flaky check so this can move forward?

@openclaw-barnacle openclaw-barnacle bot removed the stale Marked as stale due to inactivity label Mar 3, 2026
@BradGroux BradGroux self-assigned this Mar 10, 2026
@BradGroux
Copy link
Copy Markdown
Contributor

Hi @lewiswigmore — thanks for the submission. I’m the new Microsoft Teams maintainer for OpenClaw. Please give me a day or two to work through the open Teams backlog. Also, join the Twitter community for daily MS Teams feedback + updates: https://x.com/i/communities/2031170403607974228

1 similar comment
@BradGroux
Copy link
Copy Markdown
Contributor

Hi @lewiswigmore — thanks for the submission. I’m the new Microsoft Teams maintainer for OpenClaw. Please give me a day or two to work through the open Teams backlog. Also, join the Twitter community for daily MS Teams feedback + updates: https://x.com/i/communities/2031170403607974228

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants