ICP-by-platform · Microsoft Teams

Prompt-injection scanner for Microsoft Teams bots

Teams bots built on the Bot Framework SDK (C# or Node.js) and the Teams AI Library v1.x receive image attachments as activity.attachments entries with a contentUrl pointing to a SharePoint or OneDrive blob. The common integration pattern fetches that blob using a Graph API token, then forwards the raw bytes directly to GPT-4o Vision — with no intermediate scan. Any member of a Teams channel, including external guests added via Teams Shared Channels (cross-tenant AAD identities), can craft an image containing pixel-level adversarial instructions that redirect the bot's LLM output. Inserting a Glyphward scan between the Graph API fetch and the LLM call closes this attack surface with a single await.

TL;DR

In your Bot Framework onMessage handler (or Teams AI Library app.message() handler), after fetching the image bytes from attachment.contentUrl with a Graph API Bearer token, call POST https://glyphward.com/v1/scan with the base64-encoded image. If the returned score ≥ 70 (lower to 60 for bots deployed to Teams Shared Channels with external guests), reject the message via context.sendActivity() and return without calling the LLM. Free tier — 10 scans/day, no card required.

Attack surface: where Teams bots receive unscanned images

Bot Framework SDK onMessage handlers and fileConsentActivity. When a Teams user uploads an image directly in a chat with a bot, the Bot Framework delivers either a fileConsent/invoke activity (for file upload consent flows) or an onMessage activity whose activity.attachments array includes entries with contentType starting with image/ and a contentUrl pointing to the uploaded file on SharePoint or Azure Blob Storage. Bots that auto-accept these attachments and forward them to a vision model are exposed to any channel member who can send a message.

Teams AI Library v1.x app.message() handlers. The Teams AI Library wraps Bot Framework's activity dispatching with a higher-level app.message() routing API that makes it easy to add GPT-4o Vision to a bot. Because the library abstracts away the attachment download, developers often don't realise the image bytes flow directly from the Teams client to the LLM with nothing in between. The attachment URL pattern is identical to raw Bot Framework: activity.attachments[0].contentUrl requires a Graph API token to resolve.

Proactive messaging bots fetching from SharePoint and OneDrive via Graph API. Bots that operate proactively — polling a mailbox, watching a SharePoint document library, or reacting to a Power Automate trigger — may fetch images from SharePoint or OneDrive using https://graph.microsoft.com/v1.0/drives/{driveId}/items/{itemId}/content. These images originate from user uploads in SharePoint document libraries or Teams channel files tabs, meaning any collaborator with contribute permissions can supply a malicious image.

Teams Personal and Group Tab image upload UIs. Teams tabs (React SPAs running in an iframe inside Teams) let users pick and upload images through a file picker, then POST them to the bot's backend via an Azure Function or API endpoint. If the backend routes those bytes to an LLM for classification or summarisation without scanning, the tab's image upload UI is an unguarded injection entry point reachable from any device with access to the Teams client.

External guests via Teams Shared Channels. Teams Shared Channels (Teams Connect) let users from external Azure AD tenants join a Teams channel as first-class members — they appear in the channel roster, can send messages, and can upload files. A bot deployed to a channel that includes Shared Channel guests accepts image uploads from identities outside your organisation's AAD tenant. These are fully untrusted principals: they were onboarded by a guest invitation process, not by your IT team's identity governance controls. Treat all image attachments in Shared Channel-exposed channels as untrusted input regardless of the sender's display name.

The canonical attack scenario. A member of a Teams channel — or an external guest invited via a Shared Channel — sends a PNG image where adversarial instructions are encoded at the pixel level: low-contrast text, steganographic overlays, or typographic manipulation that the human eye cannot resolve but a vision model reads as system-level commands. The bot's onMessage handler fires, iterates activity.attachments, fetches the image bytes from the contentUrl using a Graph API Bearer token, and passes the base64-encoded bytes to openai.chat.completions.create() with a image_url message part. The model follows the hidden instructions — exfiltrating prior conversation context, changing the bot's response tone, or calling a tool it should not. No scan happens. Azure Content Safety does not inspect image bytes in this pipeline; it is not wired into Bot Framework's activity handler lifecycle.

Integration: Bot Framework SDK for Node.js

The handler below wires into a standard Bot Framework ActivityHandler subclass. It iterates activity.attachments, fetches each image attachment via the Graph API Bearer token already available in the activity context, scans with Glyphward before any LLM call, and fails closed if the scanner is unreachable.

const { ActivityHandler } = require('botbuilder');
const axios = require('axios');
const { OpenAI } = require('openai');

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

class TeamsImageBot extends ActivityHandler {
  constructor() {
    super();

    this.onMessage(async (context, next) => {
      const activity = context.activity;

      // Collect image attachments from the Teams activity
      const imageAttachments = (activity.attachments || []).filter(
        (a) => a.contentType && a.contentType.startsWith('image/')
      );

      if (imageAttachments.length === 0) {
        // No image — handle as plain text message
        await context.sendActivity('No image attached. Send an image to analyse.');
        return await next();
      }

      for (const attachment of imageAttachments) {
        // Fetch image bytes from contentUrl using the Graph API token
        // Teams attachment contentUrls require a Bearer token from the bot's
        // Microsoft App credentials — not the user's token.
        let imageBase64;
        try {
          const imageResp = await axios.get(attachment.contentUrl, {
            headers: {
              Authorization: `Bearer ${process.env.TEAMS_GRAPH_API_TOKEN}`,
            },
            responseType: 'arraybuffer',
          });
          imageBase64 = Buffer.from(imageResp.data).toString('base64');
        } catch (fetchErr) {
          await context.sendActivity('Could not retrieve the attached image. Please try again.');
          return await next();
        }

        // Scan for multimodal prompt injection before the LLM call
        let scanResult;
        try {
          const scanResp = await axios.post(
            'https://glyphward.com/v1/scan',
            { image: imageBase64, source: 'teams_bot' },
            {
              headers: {
                Authorization: `Bearer ${process.env.GLYPHWARD_API_KEY}`,
                'Content-Type': 'application/json',
              },
              timeout: 8000,
            }
          );
          scanResult = scanResp.data;
        } catch (scanErr) {
          // Fail-closed: scanner unreachable → do not forward image to LLM
          await context.sendActivity(
            'Image security check is temporarily unavailable. Please retry in a moment.'
          );
          return await next();
        }

        // Threshold: 70 for internal channels; lower to 60 for Shared Channels
        // with external AAD guests (set via env var per channel configuration)
        const threshold = parseInt(process.env.GLYPHWARD_THRESHOLD ?? '70', 10);

        if (scanResult.score >= threshold) {
          await context.sendActivity(
            `Image blocked by security scanner (risk score ${scanResult.score}/100). ` +
            `External or adversarial content was detected. Ref: \`${scanResult.scan_id}\``
          );
          return await next();
        }

        // Safe: forward to GPT-4o Vision
        const completion = await openai.chat.completions.create({
          model: 'gpt-4o',
          messages: [
            {
              role: 'user',
              content: [
                { type: 'text', text: 'Analyse this image and provide a summary.' },
                {
                  type: 'image_url',
                  image_url: { url: `data:${attachment.contentType};base64,${imageBase64}` },
                },
              ],
            },
          ],
          max_tokens: 512,
        });

        const reply = completion.choices[0]?.message?.content ?? 'No response from model.';
        await context.sendActivity(reply);
      }

      await next();
    });
  }
}

module.exports.TeamsImageBot = TeamsImageBot;

The TEAMS_GRAPH_API_TOKEN value is the Bot Framework service token returned by MicrosoftAppCredentials — the same token used to call the Bot Framework Connector API. For bots using the Bot Framework SDK's BotFrameworkAdapter, you can obtain it via adapter.credentials.getToken(). For bots migrated to CloudAdapter, use the ConnectorClient token provided in the activity's service URL context. The contentUrl for Teams attachments is a short-lived presigned SharePoint URL — fetch it promptly within the activity handler before it expires (typically 15–60 minutes).

Get early access

Coverage matrix

Defence layer Teams Shared Channel image (external guest) SharePoint-linked image attachment Tab image upload External guest file share
WAF (Azure Front Door / App Gateway) No — pixel-level content not inspected No No No
Text-only scanner (LLM Guard, Lakera) No — image bytes ignored entirely No No No
Azure Content Safety (text mode) No — text-only Teams integration; image bytes not routed through ACS in Bot Framework pipeline No No No
Glyphward pre-LLM scan Yes — pixel-level adversarial text detection Yes Yes Yes

Related questions

How does this differ from Azure Content Safety for Teams bots?

Azure Content Safety's Teams integration is designed to moderate text content — messages, conversation history, and text extracted from documents. It does not inspect the raw image bytes of an attachment for adversarial pixel-level instructions. When a Teams bot fetches an image via activity.attachments[0].contentUrl and passes the bytes to a vision model, Azure Content Safety is not part of that pipeline unless you explicitly call the ACS Image Analysis API yourself. Even then, ACS's image moderation API is tuned for harmful content categories (violence, sexual content, hate) — it is not a prompt-injection detector. Glyphward is purpose-built for adversarial typographic and steganographic injection payloads embedded in image pixels.

Does this work with Teams AI Library v1.x app.message() handlers?

Yes. The Teams AI Library's app.message() handler exposes the same Bot Framework TurnContext and activity object as a raw ActivityHandler.onMessage() handler. The attachment structure is identical: context.activity.attachments contains entries with contentType and contentUrl. The scan pattern is the same — filter for image/* content types, fetch the bytes using a Graph API Bearer token, call POST https://glyphward.com/v1/scan with the base64 payload, check score ≥ 70, and only proceed to the AI call on a clean result. The Teams AI Library's planner and action execution chain sits after the app.message() handler, so inserting the scan here gates all downstream AI processing.

What about Adaptive Card image submissions?

Adaptive Cards in Teams can include file picker inputs (Input.ChoiceSet with file-type constraints, or Action.Submit payloads carrying attachment metadata) that resolve to the same contentUrl pattern as direct message attachments. When a user submits an Adaptive Card that includes an image reference, the bot receives an invoke activity with an adaptiveCard/action event type. The attachment URL in the card's submitted data points to the same SharePoint blob storage. Apply the same scan pattern: extract the image URL from the card submission data, fetch the bytes, call Glyphward before passing to the LLM. The threshold and fail-closed logic should match your onMessage handler.

How do Teams Shared Channels change the trust boundary?

Teams Shared Channels (Teams Connect) allow members from external Azure AD tenants to participate in your Teams channel with their home-tenant identities. Unlike standard guest accounts (which are represented as B2B guest objects in your tenant's AAD), Shared Channel members remain in their home tenant's AAD and are only federated into your channel. This means your bot cannot rely on your tenant's Conditional Access policies, MFA requirements, or identity governance controls to vet these users. For bots deployed to channels with Shared Channel members, treat all image attachments as fully untrusted regardless of the sender's AAD identity. Reduce the Glyphward scan threshold from 70 to 60 (GLYPHWARD_THRESHOLD=60 in your bot's environment) to flag lower-confidence adversarial signals. You can detect Shared Channel membership by checking whether activity.channelData.team.type equals sharedChannel in the Bot Framework activity payload and branching the threshold accordingly in your handler.

Further reading