Files
PowerToys/src/common/FilePreviewCommon/HTMLParsingExtension.cs
st-gr 0452638470 feat(File Explorer): Add configurable local image rendering to Markdown previewer (#47857)
## Summary
- Adds a "Show local images" toggle in PowerToys Settings (File Explorer
> Markdown)
- When enabled, renders images referenced via relative paths or local
file paths in the Markdown preview pane
- Serves validated local image files on a WebView2 virtual host
(`https://localmdimages/`) directly from the handler's resource filter
- Supports local paths and UNC/network share paths
- Default: OFF (preserves existing behavior)
- GPO support: Admins can force-enable or force-disable via Group Policy

Fixes #40787
Fixes #3713

## Security model

| Scenario | Behavior |
|----------|----------|
| Setting OFF (default) | All images blocked, info bar shown. Raw HTML
`src` is rewritten to `#` in this state too, so a `data:` image cannot
render (it is resolved internally and never reaches the resource filter)
|
| Setting ON + relative path (`media/img.png`) | Resolved against .md
directory, rendered if under that tree |
| Setting ON + path traversal (`../../secret.png`) | Blocked — resolved
with `Path.GetFullPath` and checked with `Path.GetRelativePath`,
including percent-encoded traversal on the serving side |
| Setting ON + junction/symlink below the allowed path | Blocked — each
component of the resolved path is rejected if it carries
`FileAttributes.ReparsePoint`, since lexical containment alone does not
prevent redirection |
| Setting ON + UNC relative path (`images/pic.png` on `\\server\share`)
| Allowed within share root |
| Setting ON + remote URL (`https://evil.com/track.png`) | Always
blocked |
| data:/javascript: URI | Always blocked, in both setting states |
| `srcset` on a raw HTML `<img>` | Attribute removed, in both setting
states — its candidates are not validated by the `src` sanitizer |
| Script execution | Always disabled (`IsScriptEnabled = false`) |
| Mark-of-the-Web (MotW) | Explorer blocks preview of MotW-tagged files
before our code runs (OS-level protection) |

## GPO Policy

- Policy name: `MarkdownAllowLocalImages`
- Registry: `HKLM\SOFTWARE\Policies\PowerToys\MarkdownAllowLocalImages`
(DWORD: 1=enabled, 0=disabled)
- ADMX category: **PowerToys > File Explorer Preview**
- Uses `getConfiguredValue()` (individual module setting pattern, no
global utility fallback)

## Screenshots

### Settings UI — new toggle
_"Show local images" toggle nested under the Markdown preview section
(File Explorer add-ons). Captured from a Debug build of this branch (the
Settings app only runs standalone in Debug builds):_

<img width="1904" height="1014" alt="07-settings-ui-toggle"
src="https://raw.githubusercontent.com/st-gr/PowerToys/pr-47857-assets/07-settings-ui-toggle.png"
/>

### Settings UI — locked by GPO
_With the `MarkdownAllowLocalImages` policy set to Disabled, the toggle
is forced Off and grayed out, and the "managed by your organization"
info bar appears:_

<img width="1904" height="1014" alt="08-settings-ui-gpo-locked"
src="https://raw.githubusercontent.com/st-gr/PowerToys/pr-47857-assets/08-settings-ui-gpo-locked.png"
/>

### GPO in Group Policy Editor
_New "File Explorer Preview" category under PowerToys, showing the
policy and its description:_

<img width="1472" height="847" alt="01-gpedit-category"
src="https://github.com/user-attachments/assets/54acb539-345b-4512-9685-35930966a142"
/>

### GPO set to Enabled
_Policy enabled state in gpedit.msc:_

<img width="1473" height="848" alt="02-gpedit-policy-enabled"
src="https://github.com/user-attachments/assets/883f6b22-179d-4311-983d-2d835e4d897a"
/>

### Preview with local images rendered
_Markdown preview with local image rendering enabled — relative path
image renders:_

<img width="1430" height="881" alt="03-preview-images-shown"
src="https://github.com/user-attachments/assets/4a7ac7f0-d57d-4feb-bf2f-7e3c9093ab52"
/>

### Info bar for blocked remote images
_When the document contains remote (http/https) image URLs, they are
always blocked and an info bar is shown:_

<img width="1412" height="1035" alt="04-preview-infobar"
src="https://github.com/user-attachments/assets/0f798bbc-ae1b-43bf-b343-394080fff8e3"
/>

### GPO disabled — all images blocked
_With GPO set to disabled, all images (local and remote) are blocked.
Info bar reads "Some pictures have been blocked...":_

<img width="1417" height="704" alt="05-gpo-disabled-blocked"
src="https://github.com/user-attachments/assets/1c7a09a7-f4b9-467c-a0c0-f670680d6a2d"
/>

### Mark-of-the-Web protection
_Files copied from a network source carry a Zone Identifier (MotW).
Explorer blocks the preview entirely before our code runs — an OS-level
security layer:_

<img width="1114" height="591" alt="06-MotW-tagged"
src="https://github.com/user-attachments/assets/09cc2055-1e12-4c11-81fb-abd83ab8249c"
/>

## Implementation

Two layers were blocking images:
1. **Markdig AST layer** (`HTMLParsingExtension.cs`): replaced image
URLs with `#`
2. **WebView2 layer** (`MarkdownPreviewHandlerControl.cs`): returned
HTTP 403 for all non-HTML requests

Changes:
- `HTMLParsingExtension`: conditionally resolves markdown `![](path)`
images to virtual host URLs with path traversal protection
- `MarkdownHelper`: regex-rewrites relative `src=""` in raw HTML `<img>`
tags to virtual host URLs
- `MarkdownPreviewHandlerControl`: serves `https://localmdimages/`
requests in the `WebResourceRequested` handler — the URL is resolved
back to a file path, re-validated for containment against the allowed
base path (document directory, or share root for UNC), and the bytes are
returned via `CreateWebResourceResponse` with the proper content type.
Note: `SetVirtualHostNameToFolderMapping` is deliberately NOT used for
images — WebView2 Runtime 150+ no longer serves files from UNC/network
folder mappings (verified by A/B test on 150.0.4078.48); serving from
the handler works uniformly for local and UNC paths
- Settings UI: new toggle nested under the Markdown preview expander,
with GPO lock support
- Handler `Settings.cs`: reads `EnableMdLocalImages` via
`SettingsUtils`, GPO override via `GPOWrapper`
- GPO: `gpo.h` individual module setting, ADMX/ADML with
`FileExplorerPreview` category

## Known limitation

Peek also renders Markdown through `FilePreviewCommon.MarkdownHelper`,
but calls it without the
local-images arguments, so **Peek does not show local images even when
the setting is enabled** — it
keeps the existing behavior of blocking every image. With the setting
on, the same file therefore
renders differently in the preview pane (images shown) and in Peek
(images blocked).

This is deliberate for now: wiring the setting through Peek means
changing a module that is otherwise
untouched by this PR. Verified that Peek itself is unaffected — it still
renders Markdown correctly
against the shared assembly, with images blocked as before.

## Test plan
- [x] Toggle OFF: images blocked, "pictures blocked" info bar shows
(existing behavior)
- [x] Toggle ON with relative paths: `![](images/test.png)` renders
- [x] Toggle ON with HTML img: `<img src="images/test.png">` renders
- [x] Toggle ON with path traversal: `![](../../secret.png)` — blocked
- [x] Toggle ON with remote URL: `![](https://...)` — blocked, info bar
shown
- [x] UNC path: preview works on `\\server\share\...\file.md` with
relative images
- [x] UNC path with `../` within share: allowed (resolves within share
root)
- [x] Regression tested on WebView2 Runtime 150.0.4078.48: local + UNC
images render, blocked cases (traversal, data:, remote, encoded
traversal) stay blocked
- [x] GPO Enabled: images forced on
- [x] GPO Disabled: images forced off
- [x] GPO Not Configured: user controls toggle
- [x] gpedit.msc: policy appears under PowerToys > File Explorer Preview
- [x] MotW-tagged files: Explorer blocks preview before our code runs
- [x] Settings UI toggle locked when GPO configured (grayed out for both
forced states)
- [x] Other preview handlers (Monaco, SVG, PDF) unaffected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b3fb20d-6e9d-4fef-a5cd-f8921d28c220
2026-08-14 11:07:25 +08:00

310 lines
12 KiB
C#

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Markdig;
using Markdig.Extensions.Figures;
using Markdig.Extensions.Tables;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Microsoft.PowerToys.FilePreviewCommon
{
/// <summary>
/// Callback if extension blocks external images.
/// </summary>
public delegate void ImagesBlockedCallBack();
/// <summary>
/// Markdig Extension to process html nodes in markdown AST.
/// </summary>
public class HTMLParsingExtension : IMarkdownExtension
{
/// <summary>
/// Callback if extension blocks external images.
/// </summary>
private readonly ImagesBlockedCallBack imagesBlockedCallBack;
/// <summary>
/// Initializes a new instance of the <see cref="HTMLParsingExtension"/> class.
/// </summary>
/// <param name="imagesBlockedCallBack">Callback function if image is blocked by extension.</param>
/// <param name="filePath">Absolute path of markdown file.</param>
public HTMLParsingExtension(ImagesBlockedCallBack imagesBlockedCallBack, string filePath = "")
{
this.imagesBlockedCallBack = imagesBlockedCallBack;
FilePath = filePath;
}
/// <summary>
/// Gets or sets path to directory containing markdown file.
/// </summary>
public string FilePath { get; set; }
/// <summary>
/// Gets or sets the base path used for path validation and relative URL computation.
/// For local files this equals FilePath. For UNC paths this is the share root.
/// </summary>
public string? AllowedBasePath { get; set; }
/// <summary>
/// Gets or sets a value indicating whether local images should be rendered.
/// </summary>
public bool AllowLocalImages { get; set; }
private static bool IsLocalImage([NotNullWhen(true)] string? url)
{
if (string.IsNullOrEmpty(url))
{
return false;
}
// Reject any URI-like scheme (http:, https:, data:, javascript:, file:, ...).
// A colon is only permitted as part of a drive path like "C:\" or "C:/".
int colonIndex = url.IndexOf(':');
if (colonIndex >= 0)
{
bool isDrivePath = colonIndex == 1 && char.IsLetter(url[0]) && url.Length > 2 && (url[2] == '\\' || url[2] == '/');
if (!isDrivePath)
{
return false;
}
}
return true;
}
/// <summary>
/// Validates that a local image URL resolves to a path inside the allowed base path and
/// computes the corresponding virtual host URL. Returns false for remote URLs, URI schemes
/// (data:, javascript:, file:, ...), path traversal outside the base path and malformed paths.
/// </summary>
/// <param name="url">Image URL from the markdown document.</param>
/// <param name="markdownDirectory">Directory containing the markdown file; relative URLs resolve against it.</param>
/// <param name="allowedBasePath">Base path the resolved path must be contained in. Falls back to <paramref name="markdownDirectory"/> if empty.</param>
/// <param name="virtualUrl">The rewritten virtual host URL on success.</param>
/// <returns>True if the URL is a contained local image and <paramref name="virtualUrl"/> was set.</returns>
public static bool TryGetLocalImageVirtualUrl(string? url, string markdownDirectory, string? allowedBasePath, [NotNullWhen(true)] out string? virtualUrl)
{
virtualUrl = null;
if (!IsLocalImage(url))
{
return false;
}
try
{
string basePath = Path.GetFullPath(string.IsNullOrEmpty(allowedBasePath) ? markdownDirectory : allowedBasePath);
string resolvedPath = Path.GetFullPath(Path.Combine(markdownDirectory, url));
string relativePath = Path.GetRelativePath(basePath, resolvedPath);
if (relativePath == "." || relativePath == ".." ||
relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
relativePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) ||
Path.IsPathRooted(relativePath))
{
return false;
}
// Reserved characters in a filename (#, %, ...) are not valid in a URL path, so
// escape it while keeping the directory separators intact. TryResolveVirtualUrl
// unescapes symmetrically when the request comes back.
string escapedPath = Uri.EscapeDataString(relativePath.Replace('\\', '/')).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
virtualUrl = "https://localmdimages/" + escapedPath;
return true;
}
catch (ArgumentException)
{
return false;
}
catch (PathTooLongException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
}
/// <summary>
/// Resolves a virtual host image request URL back to a file path and validates that it is
/// contained in the allowed base path. Used when serving the image bytes for a WebView2
/// resource request. Returns false for foreign hosts, empty paths, path traversal outside
/// the base path (including percent-encoded traversal) and malformed paths.
/// </summary>
/// <param name="requestUri">The request URL, expected on the localmdimages virtual host.</param>
/// <param name="allowedBasePath">Base path the resolved file must be contained in.</param>
/// <param name="resolvedPath">The validated absolute file path on success.</param>
/// <returns>True if the URL maps to a contained file path and <paramref name="resolvedPath"/> was set.</returns>
/// <remarks>Each path component is inspected for reparse points, so a path that does not
/// exist or cannot be read fails closed and returns false.</remarks>
public static bool TryResolveVirtualUrl(string? requestUri, string? allowedBasePath, [NotNullWhen(true)] out string? resolvedPath)
{
resolvedPath = null;
if (string.IsNullOrEmpty(requestUri) || string.IsNullOrEmpty(allowedBasePath))
{
return false;
}
try
{
var uri = new Uri(requestUri);
if (!string.Equals(uri.Host, "localmdimages", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string relativePath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
if (relativePath.Length == 0)
{
return false;
}
string basePath = Path.GetFullPath(allowedBasePath);
string fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
string containmentCheck = Path.GetRelativePath(basePath, fullPath);
if (containmentCheck == "." || containmentCheck == ".." ||
containmentCheck.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
containmentCheck.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) ||
Path.IsPathRooted(containmentCheck))
{
return false;
}
// The check above proves only lexical containment. A junction or symbolic link
// anywhere below the base path can still redirect the read outside it, so walk the
// resolved path back up to the base and reject any reparse point on the way. The
// base path itself is not checked: that is the document's own location.
string baseComparand = TrimTrailingSeparators(basePath);
string current = fullPath;
while (!string.Equals(TrimTrailingSeparators(current), baseComparand, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
return false;
}
string? parent = Path.GetDirectoryName(current);
if (string.IsNullOrEmpty(parent) || parent.Length >= current.Length)
{
return false;
}
current = parent;
}
resolvedPath = fullPath;
return true;
}
catch (ArgumentException)
{
return false;
}
catch (UriFormatException)
{
return false;
}
catch (PathTooLongException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static string TrimTrailingSeparators(string path)
{
string trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// Keep the separator for roots such as "C:\", where trimming changes the meaning.
return trimmed.Length == 0 || trimmed.EndsWith(Path.VolumeSeparatorChar) ? path : trimmed;
}
/// <inheritdoc/>
public void Setup(MarkdownPipelineBuilder pipeline)
{
if (pipeline != null)
{
// Make sure we don't have a delegate twice
pipeline.DocumentProcessed -= PipelineOnDocumentProcessed;
pipeline.DocumentProcessed += PipelineOnDocumentProcessed;
}
}
/// <inheritdoc/>
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
}
/// <summary>
/// Process nodes in markdown AST.
/// </summary>
/// <param name="document">Markdown Document.</param>
public void PipelineOnDocumentProcessed(MarkdownDocument document)
{
foreach (var node in document.Descendants())
{
if (node is Block)
{
if (node is Table)
{
node.GetAttributes().AddClass("table table-striped table-bordered");
}
else if (node is QuoteBlock)
{
node.GetAttributes().AddClass("blockquote");
}
else if (node is Figure)
{
node.GetAttributes().AddClass("figure");
}
else if (node is FigureCaption)
{
node.GetAttributes().AddClass("figure-caption");
}
}
else if (node is Inline)
{
if (node is LinkInline link)
{
if (link.IsImage)
{
if (AllowLocalImages && TryGetLocalImageVirtualUrl(link.Url, FilePath, AllowedBasePath, out string? virtualUrl))
{
link.Url = virtualUrl;
link.GetAttributes().AddClass("img-fluid");
}
else
{
link.Url = "#";
link.GetAttributes().AddClass("img-fluid");
imagesBlockedCallBack();
}
}
}
}
}
}
}
}