Filename HTML injection: entities bypass a raw URL blocklist
A filename filter that rejects https:// can still admit an HTML attribute containing https://. If an application inserts that filename into HTML without escaping it, the parser decodes the character references and produces a usable URL. Put the repair at the rendering boundary: render filenames as text, even when another client already validates them.
This example synthetically demonstrates the encoding mismatch. It makes no claim about a particular product, deployment, or mail-client version.
The filter and the renderer see different strings #
The vulnerable flow has two independent mistakes:
- A producer stores attacker-controlled text that another component assumes is safe because a UI restricts filename characters.
- An HTML generator interpolates that text as markup. A server-side blocklist searches its stored spelling for literal URL schemes.
An API, import job, or older client can bypass the first UI’s restrictions. The value can remain harmless in a web page that escapes it, then become markup in a generated email, export, or share preview that does not. In the API flow, validate the stored value only as data; do not treat it as safe HTML.
In an authorized test, a probe filename could contain:
<img src="https://example.invalid/pixel">.txt
Here : represents a colon and / a slash. Attribute parsing resolves those references before the image loader consumes the URL. The reserved .invalid domain keeps this example from contacting a real collection endpoint.
An image request can demonstrate remote-content loading under the tested settings. It does not establish JavaScript execution, arbitrary file access, or credential theft. A mail client may strip event handlers while retaining an image element, and it may block external images entirely.
Reproduce the mismatch without sending mail #
Save this as check_filename.py and run python3 check_filename.py. It uses Python’s HTML parser to inspect attribute values; it does not open a browser or make network requests.
from html import escape
from html.parser import HTMLParser
filename = '<img src="https://example.invalid/pixel">.txt'
assert "http://" not in filename and "https://" not in filename
class Inspect(HTMLParser):
def __init__(self):
super().__init__()
self.images = []
self.text = []
def handle_starttag(self, tag, attrs):
if tag == "img":
self.images.append(dict(attrs).get("src"))
def handle_data(self, data):
self.text.append(data)
unsafe = Inspect()
unsafe.feed(f"<p>Shared file: {filename}</p>")
unsafe.close()
assert unsafe.images == ["https://example.invalid/pixel"]
safe = Inspect()
safe.feed(f"<p>Shared file: {escape(filename, quote=True)}</p>")
safe.close()
assert safe.images == []
assert "".join(safe.text) == f"Shared file: {filename}"
print("unsafe: URL decoded; safe: filename preserved as text")
The safe version escapes the ampersands as well as the angle brackets. The parser displays the original entity spelling as filename text; it does not reparse that displayed text into another HTML element.
Prevent markup at every filename sink #
Use your template engine’s normal autoescaping for text content. In a DOM API, assign textContent; avoid passing the filename to innerHTML. Apply context-appropriate encoding if a value belongs in an attribute. A filename placed inside a URL requires URL construction and validation as well as HTML attribute escaping.
If a feature intentionally accepts rich HTML, use a maintained allowlist sanitizer with an explicit policy for external resources and URL schemes. Decoding and then extending the blocklist still leaves the application maintaining a second, incomplete interpretation of HTML. OWASP’s XSS prevention guidance covers the context-specific encoding rules.
Keep server-side filename rules for filesystem and application constraints. Those rules serve a different purpose from making text safe for an HTML sink.
Verify the actual consumer #
Test a filename created outside the restrictive UI, then follow it into each renderer. Inspect the generated HTML or MIME source as well as the visible result. For generated mail, test composition separately from recipient viewing.
On an authorized receiving client, use a controlled endpoint and record whether external content was enabled. Distinguish an outbound request from visible rendering and from script execution; one result does not prove the others. After the fix, repeat the probe and confirm that the filename appears literally, no injected element exists, and no request attributable to it occurs.
Add this filename to the HTML generator regression tests. Testing only the upload validator would miss the boundary where the text became active HTML content.