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>
This commit is contained in:
st-gr
2026-08-13 09:12:04 -07:00
committed by GitHub
parent 5193a1e497
commit 69a600e249
22 changed files with 967 additions and 16 deletions

View File

@@ -966,6 +966,7 @@ lng
LOADFROMFILE
LOBYTE
localappdata
localmdimages
localpackage
LOCALSYSTEM
LOCATIONCHANGE

View File

@@ -2,6 +2,10 @@
// 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;
@@ -43,6 +47,199 @@ namespace Microsoft.PowerToys.FilePreviewCommon
/// </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)
{
@@ -92,9 +289,17 @@ namespace Microsoft.PowerToys.FilePreviewCommon
{
if (link.IsImage)
{
link.Url = "#";
link.GetAttributes().AddClass("img-fluid");
imagesBlockedCallBack();
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();
}
}
}
}

View File

@@ -2,7 +2,9 @@
// 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.IO;
using System.Text.RegularExpressions;
using Markdig;
@@ -27,11 +29,23 @@ namespace Microsoft.PowerToys.FilePreviewCommon
public static string MarkdownHtml(string fileContent, string theme, string filePath, ImagesBlockedCallBack imagesBlockedCallBack)
{
var htmlHeader = theme == "dark" ? HtmlDarkHeader : HtmlLightHeader;
return MarkdownHtml(fileContent, theme, filePath, imagesBlockedCallBack, false, null);
}
public static string MarkdownHtml(string fileContent, string theme, string filePath, ImagesBlockedCallBack imagesBlockedCallBack, bool allowLocalImages, string? allowedBasePath)
{
// Enforce the resource policy in the browser as well as in the rewriting below: regex
// cannot cover every resource-bearing construct (objects, frames, styles, malformed
// markup), so WebView2 blocks anything the sanitizers do not catch.
string imageSourcePolicy = allowLocalImages ? "https://localmdimages" : "'none'";
string contentSecurityPolicy = $"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src {imageSourcePolicy}; object-src 'none'; frame-src 'none';\">";
var htmlHeader = (theme == "dark" ? HtmlDarkHeader : HtmlLightHeader).Insert("<!doctype html>".Length, contentSecurityPolicy);
// Extension to modify markdown AST.
HTMLParsingExtension extension = new HTMLParsingExtension(imagesBlockedCallBack);
extension.FilePath = Path.GetDirectoryName(filePath) ?? string.Empty;
extension.AllowedBasePath = allowedBasePath ?? extension.FilePath;
extension.AllowLocalImages = allowLocalImages;
// if you have a string with double space, some people view it as a new line.
// while this is against spec, even GH supports this. Technically looks like GH just trims whitespace
@@ -46,6 +60,49 @@ namespace Microsoft.PowerToys.FilePreviewCommon
MarkdownPipeline pipeline = pipelineBuilder.Build();
string parsedMarkdown = Markdown.ToHtml(fileContent, pipeline);
// srcset supports multiple candidates and descriptors, none of which the src sanitizer
// below validates. Remove the attribute rather than let a candidate through unchecked.
parsedMarkdown = Regex.Replace(
parsedMarkdown,
@"(<img\b[^>]*?)\s+srcset\s*=\s*(?:""[^""]*""|'[^']*'|[^\s>]+)",
m =>
{
imagesBlockedCallBack();
return m.Groups[1].Value;
},
RegexOptions.IgnoreCase);
// Sanitize src on raw HTML <img> tags in both setting states. Markdown images were
// already handled by the Markdig AST layer (rewritten to the virtual host URL or "#")
// and pass through unchanged. When local images are disabled everything else is blocked;
// when enabled it is validated the same way as the AST layer. Matches double-quoted,
// single-quoted and unquoted values, so every form an author can write is covered.
parsedMarkdown = Regex.Replace(
parsedMarkdown,
@"(<img\b[^>]*?\ssrc\s*=\s*)(?:(""|')(.+?)\2|([^\s""'>]+))",
m =>
{
bool isQuoted = m.Groups[2].Success;
string quote = isQuoted ? m.Groups[2].Value : "\"";
string src = isQuoted ? m.Groups[3].Value : m.Groups[4].Value;
if (src == "#" ||
(allowLocalImages && src.StartsWith("https://localmdimages/", StringComparison.OrdinalIgnoreCase)))
{
return m.Value;
}
if (allowLocalImages &&
HTMLParsingExtension.TryGetLocalImageVirtualUrl(src, extension.FilePath, extension.AllowedBasePath, out string? virtualUrl))
{
return m.Groups[1].Value + quote + virtualUrl + quote;
}
imagesBlockedCallBack();
return m.Groups[1].Value + quote + "#" + quote;
},
RegexOptions.IgnoreCase);
string markdownHTML = $"{htmlHeader}{parsedMarkdown}{HtmlFooter}";
return markdownHTML;
}

View File

@@ -60,6 +60,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredMarkdownPreviewEnabledValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredMarkdownLocalImagesEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredMarkdownLocalImagesEnabledValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredMonacoPreviewEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredMonacoPreviewEnabledValue());

View File

@@ -21,6 +21,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation
static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue();
static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredMarkdownPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredMarkdownLocalImagesEnabledValue();
static GpoRuleConfigured GetConfiguredMonacoPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue();
static GpoRuleConfigured GetConfiguredPdfPreviewEnabledValue();

View File

@@ -25,6 +25,7 @@ namespace PowerToys
static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue();
static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredMarkdownPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredMarkdownLocalImagesEnabledValue();
static GpoRuleConfigured GetConfiguredMonacoPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredPdfPreviewEnabledValue();
static GpoRuleConfigured GetConfiguredGcodePreviewEnabledValue();

View File

@@ -107,6 +107,7 @@ namespace powertoys_gpo
const std::wstring POLICY_NEW_PLUS_HIDE_TEMPLATE_FILENAME_EXTENSION = L"NewPlusHideTemplateFilenameExtension";
const std::wstring POLICY_NEW_PLUS_REPLACE_VARIABLES = L"NewPlusReplaceVariablesInTemplateFilenames";
const std::wstring POLICY_NEW_PLUS_HIDE_BUILT_IN_NEW_CONTEXT_MENU = L"NewPlusHideBuiltInNewContextMenu";
const std::wstring POLICY_MARKDOWN_ALLOW_LOCAL_IMAGES = L"MarkdownAllowLocalImages";
// Methods used for reading the registry
#pragma region ReadRegistryMethods
@@ -724,5 +725,10 @@ namespace powertoys_gpo
return getConfiguredValue(POLICY_NEW_PLUS_HIDE_BUILT_IN_NEW_CONTEXT_MENU);
}
inline gpo_rule_configured_t getConfiguredMarkdownLocalImagesEnabledValue()
{
return getConfiguredValue(POLICY_MARKDOWN_ALLOW_LOCAL_IMAGES);
}
#pragma endregion IndividualModuleSettingPolicies
}

View File

@@ -31,6 +31,7 @@
<definition name="SUPPORTED_POWERTOYS_0_98_0" displayName="$(string.SUPPORTED_POWERTOYS_0_98_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_99_0" displayName="$(string.SUPPORTED_POWERTOYS_0_99_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_100_0" displayName="$(string.SUPPORTED_POWERTOYS_0_100_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_101_0" displayName="$(string.SUPPORTED_POWERTOYS_0_101_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1)"/>
</definitions>
</supportedOn>
@@ -57,6 +58,9 @@
<category name="DeprecatedPolicies" displayName="$(string.DeprecatedPolicies)">
<parentCategory ref="PowerToys" />
</category>
<category name="FileExplorerPreview" displayName="$(string.FileExplorerPreview)">
<parentCategory ref="PowerToys" />
</category>
</categories>
<policies>
@@ -222,6 +226,16 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="MarkdownAllowLocalImages" class="Both" displayName="$(string.MarkdownAllowLocalImages)" explainText="$(string.MarkdownAllowLocalImagesDescription)" key="Software\Policies\PowerToys" valueName="MarkdownAllowLocalImages">
<parentCategory ref="FileExplorerPreview" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_101_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy name="ConfigureEnabledUtilityFileExplorerMonacoPreview" class="Both" displayName="$(string.ConfigureEnabledUtilityFileExplorerMonacoPreview)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFileExplorerMonacoPreview">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_64_0" />

View File

@@ -14,6 +14,7 @@
<string id="GeneralSettings">General settings</string>
<string id="NewPlus">New+</string>
<string id="DeprecatedPolicies">Deprecated policies</string>
<string id="FileExplorerPreview">File Explorer Preview</string>
<string id="SUPPORTED_POWERTOYS_0_64_0">PowerToys version 0.64.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_68_0">PowerToys version 0.68.0 or later</string>
@@ -38,6 +39,7 @@
<string id="SUPPORTED_POWERTOYS_0_98_0">PowerToys version 0.98.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_99_0">PowerToys version 0.99.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_100_0">PowerToys version 0.100.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_101_0">PowerToys version 0.101.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1">From PowerToys version 0.64.0 until PowerToys version 0.87.1</string>
<string id="ConfigureAllUtilityGlobalEnabledStateDescription">This policy configures the enabled state for all PowerToys utilities.
@@ -263,6 +265,17 @@ If you don't configure this policy, the user will be able to control the setting
<string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileExplorerSVGPreview">SVG file preview: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileExplorerMarkdownPreview">Markdown file preview: Configure enabled state</string>
<string id="MarkdownAllowLocalImages">Markdown preview: Show images from local and network sources</string>
<string id="MarkdownAllowLocalImagesDescription">This policy configures whether images from local and network (UNC) sources should be displayed in Markdown file previews.
If you enable this setting, images from the document's folder and network shares will always be shown and the user won't be able to disable it.
If you disable this setting, all images will be blocked and the user won't be able to enable local image rendering.
If you don't configure this setting, users are able to enable or disable showing local images.
Note: Online/remote images (http/https URLs) are always blocked regardless of this setting. Images are rendered inside a sandboxed WebView2 control with scripts disabled. The Mark-of-the-Web (Zone Identifier) is not checked; access is controlled by path validation against the document's directory (local) or share root (UNC).
</string>
<string id="ConfigureEnabledUtilityFileExplorerMonacoPreview">Source code file preview: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileExplorerPDFPreview">PDF file preview: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileExplorerGcodePreview">Gcode file preview: Configure enabled state</string>

View File

@@ -62,6 +62,7 @@
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\common\PreviewHandlerCommon.csproj" />
</ItemGroup>

View File

@@ -56,6 +56,10 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown
/// </summary>
private bool _infoBarDisplayed;
private string _markdownDirectory;
private string _allowedBasePath;
private bool _allowLocalImages;
/// <summary>
/// Gets the path of the current assembly.
/// </summary>
@@ -116,14 +120,33 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown
throw new ArgumentException($"{nameof(dataSource)} for {nameof(MarkdownPreviewHandlerControl)} must be a string but was a '{typeof(T)}'");
}
string fileText = File.ReadAllText(filePath);
Regex imageTagRegex = new Regex(@"<[ ]*img.*>");
if (imageTagRegex.IsMatch(fileText))
_allowLocalImages = Settings.GetLocalImagesEnabled();
_markdownDirectory = Path.GetDirectoryName(filePath) ?? string.Empty;
_allowedBasePath = _markdownDirectory;
if (_markdownDirectory.StartsWith(@"\\", StringComparison.Ordinal))
{
_infoBarDisplayed = true;
string trimmed = _markdownDirectory.Substring(2);
int firstSep = trimmed.IndexOf('\\');
int secondSep = firstSep >= 0 ? trimmed.IndexOf('\\', firstSep + 1) : -1;
if (secondSep >= 0)
{
_allowedBasePath = string.Concat(@"\\", trimmed.AsSpan(0, secondSep));
}
}
string markdownHTML = FilePreviewCommon.MarkdownHelper.MarkdownHtml(fileText, Settings.GetTheme(), filePath, ImagesBlockedCallBack);
string fileText = File.ReadAllText(filePath);
if (!_allowLocalImages)
{
Regex imageTagRegex = new Regex(@"<[ ]*img.*>");
if (imageTagRegex.IsMatch(fileText))
{
_infoBarDisplayed = true;
}
}
string markdownHTML = FilePreviewCommon.MarkdownHelper.MarkdownHtml(fileText, Settings.GetTheme(), filePath, ImagesBlockedCallBack, _allowLocalImages, _allowedBasePath);
_browser = new WebView2()
{
@@ -152,15 +175,44 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown
_browser.CoreWebView2.Settings.IsScriptEnabled = false;
_browser.CoreWebView2.Settings.IsWebMessageEnabled = false;
// Don't load any resources.
// Don't load any resources except virtual host mapped ones.
_browser.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
_browser.CoreWebView2.WebResourceRequested += (object sender, CoreWebView2WebResourceRequestedEventArgs e) =>
{
// Show local file we've saved with the markdown contents. Block all else.
if (new Uri(e.Request.Uri) != _localFileURI)
// Allow the local HTML file
if (_localFileURI != null && new Uri(e.Request.Uri) == _localFileURI)
{
e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null);
return;
}
// Serve virtual host image requests (localmdimages) directly. WebView2
// Runtime 150+ no longer serves UNC/network paths through
// SetVirtualHostNameToFolderMapping, so the image bytes are read here
// after re-validating the resolved path against the allowed base path.
if (_allowLocalImages && e.Request.Uri.StartsWith("https://localmdimages/", StringComparison.OrdinalIgnoreCase))
{
if (FilePreviewCommon.HTMLParsingExtension.TryResolveVirtualUrl(e.Request.Uri, _allowedBasePath, out string imagePath) && File.Exists(imagePath))
{
try
{
var imageStream = new MemoryStream(File.ReadAllBytes(imagePath));
e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(imageStream, 200, "OK", "Content-Type: " + GetImageContentType(imagePath));
return;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 404, "Not Found", null);
return;
}
// Block everything else
e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null);
};
_browser.CoreWebView2.ContextMenuRequested += (object sender, CoreWebView2ContextMenuRequestedEventArgs args) =>
@@ -217,7 +269,10 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown
if (_infoBarDisplayed)
{
_infoBar = GetTextBoxControl(Resources.BlockedImageInfoText);
string message = _allowLocalImages
? Resources.RemoteImagesBlockedInfoText
: Resources.BlockedImageInfoText;
_infoBar = GetTextBoxControl(message);
Resize += FormResized;
Controls.Add(_infoBar);
}
@@ -303,6 +358,28 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown
}
}
/// <summary>
/// Returns the HTTP Content-Type for an image file based on its extension.
/// </summary>
/// <param name="imagePath">Path of the image file.</param>
/// <returns>The content type string.</returns>
private static string GetImageContentType(string imagePath)
{
return Path.GetExtension(imagePath).ToUpperInvariant() switch
{
".PNG" => "image/png",
".JPG" or ".JPEG" => "image/jpeg",
".GIF" => "image/gif",
".BMP" => "image/bmp",
".WEBP" => "image/webp",
".SVG" => "image/svg+xml",
".ICO" => "image/x-icon",
".TIF" or ".TIFF" => "image/tiff",
".AVIF" => "image/avif",
_ => "application/octet-stream",
};
}
/// <summary>
/// Callback when image is blocked by extension.
/// </summary>

View File

@@ -78,6 +78,15 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Some online images have been blocked. Only local and network images from the document's folder are shown..
/// </summary>
internal static string RemoteImagesBlockedInfoText {
get {
return ResourceManager.GetString("RemoteImagesBlockedInfoText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string for an error when Gpo has the utility disabled.
/// </summary>

View File

@@ -125,6 +125,10 @@
<value>The markdown could not be preview due to an internal error.</value>
<comment>This text is displayed if markdown fails to preview</comment>
</data>
<data name="RemoteImagesBlockedInfoText" xml:space="preserve">
<value>Some online images have been blocked. Only local images from the document's folder and images on the same network share are shown.</value>
<comment>This text is displayed when local images are enabled but the document contains remote/online image URLs that were blocked.</comment>
</data>
<data name="GpoDisabledErrorText" xml:space="preserve">
<value>Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.</value>
<comment>GPO stands for the Windows Group Policy Object feature.</comment>

View File

@@ -4,10 +4,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.PowerToys.Settings.UI.Library;
namespace Microsoft.PowerToys.PreviewHandler.Markdown
{
internal sealed class Settings
@@ -40,5 +43,34 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown
{
return Common.UI.ThemeManager.GetWindowsBaseColor().ToLowerInvariant();
}
private static readonly SettingsUtils ModuleSettings = SettingsUtils.Default;
/// <summary>
/// Returns whether local images should be displayed in the Markdown preview.
/// GPO policy takes precedence over user setting.
/// </summary>
public static bool GetLocalImagesEnabled()
{
var gpo = global::PowerToys.GPOWrapper.GPOWrapper.GetConfiguredMarkdownLocalImagesEnabledValue();
if (gpo == global::PowerToys.GPOWrapper.GpoRuleConfigured.Enabled)
{
return true;
}
if (gpo == global::PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
return false;
}
try
{
return ModuleSettings.GetSettings<PowerPreviewSettings>(PowerPreviewSettings.ModuleName).Properties.EnableMdLocalImages;
}
catch (FileNotFoundException)
{
return false;
}
}
}
}

View File

@@ -0,0 +1,60 @@
# GPO Testing Instructions for Markdown Local Images
## Manual Registry Testing
Run these commands in an elevated (Administrator) command prompt:
```cmd
REM Force-enable local images (toggle locked ON in Settings UI):
reg add "HKLM\SOFTWARE\Policies\PowerToys" /v MarkdownAllowLocalImages /t REG_DWORD /d 1 /f
REM Force-disable local images (toggle locked OFF in Settings UI):
reg add "HKLM\SOFTWARE\Policies\PowerToys" /v MarkdownAllowLocalImages /t REG_DWORD /d 0 /f
REM Remove policy (user controls the setting):
reg delete "HKLM\SOFTWARE\Policies\PowerToys" /v MarkdownAllowLocalImages /f
```
After changing the registry value, restart PowerToys for the setting to take effect.
## Group Policy Editor Testing (gpedit.msc)
1. Copy `src/gpo/assets/PowerToys.admx` to `C:\Windows\PolicyDefinitions\`
2. Copy `src/gpo/assets/en-US/PowerToys.adml` to `C:\Windows\PolicyDefinitions\en-US\`
3. Open `gpedit.msc`
4. Navigate to: Computer Configuration > Administrative Templates > PowerToys > File Explorer Preview
5. Find "Markdown preview: Show local images - Configure enabled state"
6. Set to Enabled, Disabled, or Not Configured
## Expected Behavior Matrix
| GPO State | User Setting | Images Shown? | Settings UI Toggle | Info Bar |
|-----------|-------------|---------------|-------------------|----------|
| Not configured | OFF | No | Editable, OFF | "Some pictures have been blocked..." |
| Not configured | ON | Local/UNC only | Editable, ON | "Some online images have been blocked..." (if remote images in file) |
| Enabled | (ignored) | Local/UNC only | Locked ON (grayed out) | "Some online images have been blocked..." (if remote images in file) |
| Disabled | (ignored) | No | Locked OFF (grayed out) | "Some pictures have been blocked..." |
## Verification Steps
1. Set GPO to "Enabled" via registry
2. Open PowerToys Settings > File Explorer > Markdown
3. Verify the "Show local images" toggle is ON and grayed out (cannot be changed)
4. Preview `test-local-images.md` in File Explorer
5. Verify local image renders, remote image is blocked with info bar
6. Set GPO to "Disabled" via registry
7. Restart PowerToys
8. Verify the toggle is OFF and grayed out
9. Preview the same file
10. Verify all images are blocked
11. Remove the GPO registry value
12. Restart PowerToys
13. Verify the toggle is editable again
## Notes
- Machine scope (HKLM) takes precedence over user scope (HKCU)
- The "Some preview handlers are managed by your organization" info bar will appear in Settings when any GPO is configured
- Remote/online images (http/https URLs) are ALWAYS blocked regardless of this setting

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,46 @@
# Markdown Preview - Local Images Test
This file tests the local image rendering feature of the PowerToys Markdown preview handler.
## Expected Behavior
| Setting State | Local Images | Remote Images | Info Bar |
|---------------|-------------|---------------|----------|
| Toggle OFF | Blocked | Blocked | "Some pictures have been blocked..." |
| Toggle ON | Shown | Blocked | "Some online images have been blocked..." |
| GPO Enabled | Shown | Blocked | "Some online images have been blocked..." |
| GPO Disabled | Blocked | Blocked | "Some pictures have been blocked..." |
## Local Image (relative path)
This image should render when "Show local images" is enabled:
![Test Image](images/test.png)
## Local Image (HTML img tag)
<img src="images/test.png" alt="HTML img tag test" width="32" height="32">
## Remote Image (should always be blocked)
This image should never render (online URL):
![Remote Image](https://example.com/nonexistent-image.png)
## UNC Path Image (for network testing)
When the markdown file is on a network share, the following would render:
<!-- Uncomment and adjust for your network environment:
![Network Image](\\server\share\images\test.png)
-->
Note: UNC images are allowed when:
- The markdown file itself is on a UNC share, AND
- The image is on the same share root (e.g., same `\\server\share\`)
## Path Traversal (should always be blocked)
This attempts to escape the document directory and should be blocked:
![Traversal](../../../../../../Windows/System32/notepad.exe)

View File

@@ -2,6 +2,9 @@
// 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.IO;
using Markdig;
using Microsoft.PowerToys.PreviewHandler.Markdown;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -100,5 +103,326 @@ namespace PreviewPaneUnitTests
const string expected = "<p><img src=\"#\" class=\"img-fluid\" alt=\"text\" title=\"Figure\" /></p>\n";
Assert.AreEqual(expected, html);
}
[DataTestMethod]
[DataRow("images/test.png", @"C:\docs", @"C:\docs", "https://localmdimages/images/test.png")]
[DataRow(@"C:\docs\images\test.png", @"C:\docs", @"C:\docs", "https://localmdimages/images/test.png")]
[DataRow("images/test.png", @"\\server\share\sub\dir", @"\\server\share", "https://localmdimages/sub/dir/images/test.png")]
[DataRow("../test.png", @"\\server\share\sub", @"\\server\share", "https://localmdimages/test.png")]
public void TryGetLocalImageVirtualUrlAllowsContainedPaths(string url, string markdownDirectory, string basePath, string expectedVirtualUrl)
{
bool result = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryGetLocalImageVirtualUrl(url, markdownDirectory, basePath, out string virtualUrl);
Assert.IsTrue(result);
Assert.AreEqual(expectedVirtualUrl, virtualUrl);
}
[DataTestMethod]
[DataRow("http://example.com/a.png", @"C:\docs", @"C:\docs")]
[DataRow("https://example.com/a.png", @"C:\docs", @"C:\docs")]
[DataRow("data:image/png;base64,iVBORw0KGgo=", @"C:\docs", @"C:\docs")]
[DataRow("javascript:alert(1)", @"C:\docs", @"C:\docs")]
[DataRow("file:///C:/secret.png", @"C:\docs", @"C:\docs")]
[DataRow("../secret.png", @"C:\docs", @"C:\docs")]
[DataRow(@"..\..\secret.png", @"C:\docs\sub", @"C:\docs\sub")]
[DataRow(@"C:\other\secret.png", @"C:\docs", @"C:\docs")]
[DataRow(@"C:\docsBackup\secret.png", @"C:\docs", @"C:\docs")]
[DataRow(@"\\server\share2\secret.png", @"\\server\share\sub", @"\\server\share")]
[DataRow("", @"C:\docs", @"C:\docs")]
public void TryGetLocalImageVirtualUrlBlocksUnsafeUrls(string url, string markdownDirectory, string basePath)
{
bool result = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryGetLocalImageVirtualUrl(url, markdownDirectory, basePath, out string virtualUrl);
Assert.IsFalse(result);
Assert.IsNull(virtualUrl);
}
// Resolving checks each path component for reparse points, so these cases operate on real
// files rather than notional paths.
[DataTestMethod]
[DataRow("images/test.png", "https://localmdimages/images/test.png")]
[DataRow("sub/dir/images/test.png", "https://localmdimages/sub/dir/images/test.png")]
[DataRow("my image.png", "https://localmdimages/my%20image.png")]
public void TryResolveVirtualUrlAllowsContainedRequests(string relativePath, string requestUri)
{
string root = Path.Combine(Path.GetTempPath(), "ptmd-" + Guid.NewGuid().ToString("N"));
string expectedPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(expectedPath));
File.WriteAllText(expectedPath, "not really an image");
try
{
bool result = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryResolveVirtualUrl(requestUri, root, out string resolvedPath);
Assert.IsTrue(result);
Assert.AreEqual(expectedPath, resolvedPath);
}
finally
{
try
{
Directory.Delete(root, true);
}
catch (IOException)
{
}
}
}
[TestMethod]
public void TryResolveVirtualUrlRejectsMissingFile()
{
string root = Path.Combine(Path.GetTempPath(), "ptmd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
bool result = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryResolveVirtualUrl(
"https://localmdimages/does-not-exist.png", root, out string resolvedPath);
Assert.IsFalse(result, "a path that cannot be inspected must fail closed");
Assert.IsNull(resolvedPath);
}
finally
{
try
{
Directory.Delete(root, true);
}
catch (IOException)
{
}
}
}
[DataTestMethod]
[DataRow("https://localmdimages/.." + "%2F" + "secret.png", @"C:\docs")]
[DataRow("https://localmdimages/.." + "%5C" + "secret.png", @"C:\docs")]
[DataRow("https://localmdimages/C" + "%3A%5C" + "other" + "%5C" + "secret.png", @"C:\docs")]
[DataRow("https://example.com/images/test.png", @"C:\docs")]
[DataRow("https://localmdimages/", @"C:\docs")]
[DataRow("not a url", @"C:\docs")]
[DataRow("", @"C:\docs")]
public void TryResolveVirtualUrlBlocksUnsafeRequests(string requestUri, string basePath)
{
bool result = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryResolveVirtualUrl(requestUri, basePath, out string resolvedPath);
Assert.IsFalse(result);
Assert.IsNull(resolvedPath);
}
[DataTestMethod]
[DataRow("<img src=\"images/test.png\" />")]
[DataRow("<img src='images/test.png' />")]
[DataRow("<img src=images/test.png />")]
[DataRow("<img alt=\"x\" SRC = \"images/test.png\" />")]
public void RawHtmlImageIsRewrittenForEveryQuoteStyle(string mdString)
{
string html = Microsoft.PowerToys.FilePreviewCommon.MarkdownHelper.MarkdownHtml(
mdString, "light", @"C:\docs\doc.md", () => { }, true, @"C:\docs");
StringAssert.Contains(html, "https://localmdimages/images/test.png");
}
[DataTestMethod]
[DataRow("<img src=\"https://example.com/track.png\" />")]
[DataRow("<img src='https://example.com/track.png' />")]
[DataRow("<img src=https://example.com/track.png />")]
[DataRow("<img src='../secret.png' />")]
[DataRow("<img src='data:image/png;base64,iVBORw0KGgo=' />")]
public void RawHtmlImageIsBlockedForEveryQuoteStyle(string mdString)
{
int blockedCount = 0;
string html = Microsoft.PowerToys.FilePreviewCommon.MarkdownHelper.MarkdownHtml(
mdString, "light", @"C:\docs\doc.md", () => { blockedCount++; }, true, @"C:\docs");
Assert.AreNotEqual(0, blockedCount, "the blocked-images callback should fire so the info bar is shown");
StringAssert.Contains(html, "src=");
Assert.IsFalse(html.Contains("example.com"), "remote source must not survive the rewrite");
Assert.IsFalse(html.Contains("secret.png"), "traversal source must not survive the rewrite");
Assert.IsFalse(html.Contains("base64"), "data URI must not survive the rewrite");
}
[DataTestMethod]
[DataRow(true)]
[DataRow(false)]
public void RawHtmlSrcsetIsRemovedInBothSettingStates(bool allowLocalImages)
{
int blockedCount = 0;
string mdString = "<img src=\"images/test.png\" srcset=\"data:image/png;base64,iVBORw0KGgo= 2x\" />";
string html = Microsoft.PowerToys.FilePreviewCommon.MarkdownHelper.MarkdownHtml(
mdString, "light", @"C:\docs\doc.md", () => { blockedCount++; }, allowLocalImages, @"C:\docs");
Assert.IsFalse(html.Contains("srcset"), "srcset must be removed so its candidates cannot bypass the src sanitizer");
Assert.IsFalse(html.Contains("base64"), "the srcset data URI must not survive");
Assert.AreNotEqual(0, blockedCount);
}
[DataTestMethod]
[DataRow("<img src=\"data:image/png;base64,iVBORw0KGgo=\" />", "base64")]
[DataRow("<img src=\"https://example.com/track.png\" />", "example.com")]
[DataRow("<img src=\"images/test.png\" />", "images/test.png")]
public void RawHtmlSrcIsSanitizedWhenLocalImagesDisabled(string mdString, string forbidden)
{
int blockedCount = 0;
string html = Microsoft.PowerToys.FilePreviewCommon.MarkdownHelper.MarkdownHtml(
mdString, "light", @"C:\docs\doc.md", () => { blockedCount++; }, false, @"C:\docs");
Assert.IsFalse(html.Contains(forbidden), "raw HTML img sources must be blocked while the setting is off");
StringAssert.Contains(html, "src=\"#\"");
Assert.AreNotEqual(0, blockedCount);
}
[TestMethod]
public void TryResolveVirtualUrlRejectsPathBehindDirectoryLink()
{
string root = Path.Combine(Path.GetTempPath(), "ptmd-" + Guid.NewGuid().ToString("N"));
string allowed = Path.Combine(root, "allowed");
string outside = Path.Combine(root, "outside");
Directory.CreateDirectory(allowed);
Directory.CreateDirectory(outside);
File.WriteAllText(Path.Combine(outside, "secret.png"), "not really an image");
try
{
string link = Path.Combine(allowed, "link");
try
{
Directory.CreateSymbolicLink(link, outside);
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
Assert.Inconclusive("Creating a directory link requires privilege on this machine.");
return;
}
// Lexically "link/secret.png" sits inside the allowed directory, but the read would
// follow the link outside it.
bool result = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryResolveVirtualUrl(
"https://localmdimages/link/secret.png", allowed, out string resolvedPath);
Assert.IsFalse(result, "a path traversing a reparse point must be rejected");
Assert.IsNull(resolvedPath);
}
finally
{
try
{
Directory.Delete(root, true);
}
catch (IOException)
{
}
}
}
[DataTestMethod]
[DataRow(true, "img-src https://localmdimages")]
[DataRow(false, "img-src 'none'")]
public void ContentSecurityPolicyMatchesTheSettingState(bool allowLocalImages, string expectedImgSrc)
{
string html = Microsoft.PowerToys.FilePreviewCommon.MarkdownHelper.MarkdownHtml(
"# heading", "light", @"C:\docs\doc.md", () => { }, allowLocalImages, @"C:\docs");
StringAssert.Contains(html, "http-equiv=\"Content-Security-Policy\"");
StringAssert.Contains(html, expectedImgSrc);
StringAssert.Contains(html, "default-src 'none'");
StringAssert.Contains(html, "object-src 'none'");
StringAssert.Contains(html, "frame-src 'none'");
// The policy has to precede the content it governs.
Assert.IsTrue(
html.IndexOf("Content-Security-Policy", StringComparison.Ordinal) < html.IndexOf("<body", StringComparison.Ordinal),
"the policy must appear before the body so the parser places it in the head");
}
[TestMethod]
public void FourArgumentOverloadStillBlocksImages()
{
int blockedCount = 0;
// Callers compiled against the original signature (Peek) must keep working.
string html = Microsoft.PowerToys.FilePreviewCommon.MarkdownHelper.MarkdownHtml(
"![text](images/test.png)", "light", @"C:\docs\doc.md", () => { blockedCount++; });
StringAssert.Contains(html, "src=\"#\"");
StringAssert.Contains(html, "img-src 'none'");
Assert.AreNotEqual(0, blockedCount);
}
[DataTestMethod]
[DataRow("images/a#b.png")]
[DataRow("images/a%20b.png")]
[DataRow("images/a b.png")]
[DataRow("images/a&b.png")]
public void VirtualUrlRoundTripsFilenamesWithReservedCharacters(string relativePath)
{
string root = Path.Combine(Path.GetTempPath(), "ptmd-" + Guid.NewGuid().ToString("N"));
string onDisk = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(onDisk));
File.WriteAllText(onDisk, "not really an image");
try
{
bool built = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryGetLocalImageVirtualUrl(
relativePath, root, root, out string virtualUrl);
Assert.IsTrue(built, "the URL should be produced");
// A URL carrying a raw '#' or '%' would be truncated or misparsed here.
bool resolved = Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension.TryResolveVirtualUrl(
virtualUrl, root, out string resolvedPath);
Assert.IsTrue(resolved, $"the escaped URL '{virtualUrl}' should resolve back");
Assert.AreEqual(onDisk, resolvedPath);
}
finally
{
try
{
Directory.Delete(root, true);
}
catch (IOException)
{
}
}
}
[TestMethod]
public void ExtensionRewritesLocalImageToVirtualHostWhenLocalImagesAllowed()
{
// arrange
string mdString = "![text](images/test.png)";
Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension htmlParsingExtension = new Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension(() => { }, @"C:\docs");
htmlParsingExtension.AllowLocalImages = true;
MarkdownPipeline markdownPipeline = BuildPipeline(htmlParsingExtension);
// Act
string html = Markdown.ToHtml(mdString, markdownPipeline);
// Assert
const string expected = "<p><img src=\"https://localmdimages/images/test.png\" class=\"img-fluid\" alt=\"text\" /></p>\n";
Assert.AreEqual(expected, html);
}
[TestMethod]
public void ExtensionBlocksPathTraversalAndMakesCallbackWhenLocalImagesAllowed()
{
// arrange
int count = 0;
string mdString = "![text](../secret.png)";
Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension htmlParsingExtension = new Microsoft.PowerToys.FilePreviewCommon.HTMLParsingExtension(() => { count++; }, @"C:\docs");
htmlParsingExtension.AllowLocalImages = true;
MarkdownPipeline markdownPipeline = BuildPipeline(htmlParsingExtension);
// Act
string html = Markdown.ToHtml(mdString, markdownPipeline);
// Assert
Assert.AreEqual(1, count);
const string expected = "<p><img src=\"#\" class=\"img-fluid\" alt=\"text\" /></p>\n";
Assert.AreEqual(expected, html);
}
}
}

View File

@@ -81,6 +81,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
private bool enableMdLocalImages;
[JsonPropertyName("md-previewer-local-images-setting")]
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool EnableMdLocalImages
{
get => enableMdLocalImages;
set
{
if (value != enableMdLocalImages)
{
LogTelemetryEvent(value);
enableMdLocalImages = value;
}
}
}
private bool enableMonacoPreview = true;
[JsonPropertyName("monaco-previewer-toggle-setting")]

View File

@@ -132,7 +132,7 @@
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
<tkcontrols:SettingsCard
<tkcontrols:SettingsExpander
Name="FileExplorerPreviewToggleSwitchPreviewMD"
x:Uid="FileExplorerPreview_ToggleSwitch_Preview_MD"
HeaderIcon="{ui:FontIcon Glyph=&#xE943;}"
@@ -141,7 +141,34 @@
x:Uid="ToggleSwitch"
IsEnabled="{x:Bind ViewModel.MDRenderIsGpoEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
IsOn="{x:Bind ViewModel.MDRenderIsEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard x:Uid="FileExplorerPreview_ToggleSwitch_Preview_MD_LocalImages" IsEnabled="{x:Bind ViewModel.MDRenderIsEnabled, Mode=OneWay}">
<ToggleSwitch
x:Uid="ToggleSwitch"
IsEnabled="{x:Bind ViewModel.MDLocalImagesIsGPOConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
IsOn="{x:Bind ViewModel.MDLocalImagesIsEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Padding="0"
HorizontalContentAlignment="Stretch"
Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}"
ContentAlignment="Vertical"
Visibility="{x:Bind ViewModel.MDLocalImagesIsGPOConfigured, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<InfoBar
x:Uid="GPO_SettingIsManaged"
Background="Transparent"
BorderBrush="Transparent"
IsClosable="False"
IsOpen="True"
IsTabStop="{x:Bind ViewModel.MDLocalImagesIsGPOConfigured, Mode=OneWay}"
Severity="Informational">
<InfoBar.IconSource>
<FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE72E;" />
</InfoBar.IconSource>
</InfoBar>
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
<tkcontrols:SettingsCard
Name="FileExplorerPreviewToggleSwitchPreviewPDF"

View File

@@ -1088,6 +1088,12 @@ opera.exe</value>
<value>.md, .markdown, .mdown, .mkdn, .mkd, .mdwn, .mdtxt, .mdtext</value>
<comment>{Locked}</comment>
</data>
<data name="FileExplorerPreview_ToggleSwitch_Preview_MD_LocalImages.Header" xml:space="preserve">
<value>Show local images</value>
</data>
<data name="FileExplorerPreview_ToggleSwitch_Preview_MD_LocalImages.Description" xml:space="preserve">
<value>Display images stored in the Markdown document's folder, including images on the same network share, in the preview. Remote/online images remain blocked.</value>
</data>
<data name="FileExplorerPreview_ToggleSwitch_Preview_Monaco.Header" xml:space="preserve">
<value>Source code files (Monaco)</value>
<comment>File type, do not translate</comment>

View File

@@ -76,6 +76,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_mdRenderIsEnabled = Settings.Properties.EnableMdPreview;
}
_mdLocalImagesEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredMarkdownLocalImagesEnabledValue();
if (_mdLocalImagesEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _mdLocalImagesEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
{
_mdLocalImagesEnabledStateIsGPOConfigured = true;
_mdLocalImagesEnabled = _mdLocalImagesEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled;
}
else
{
_mdLocalImagesEnabled = Settings.Properties.EnableMdLocalImages;
}
_monacoRenderEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredMonacoPreviewEnabledValue();
if (_monacoRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _monacoRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
{
@@ -254,6 +265,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private bool _mdRenderIsGpoEnabled;
private bool _mdRenderIsGpoDisabled;
private bool _mdRenderIsEnabled;
private GpoRuleConfigured _mdLocalImagesEnabledGpoRuleConfiguration;
private bool _mdLocalImagesEnabledStateIsGPOConfigured;
private bool _mdLocalImagesEnabled;
private GpoRuleConfigured _monacoRenderEnabledGpoRuleConfiguration;
private bool _monacoRenderEnabledStateIsGPOConfigured;
@@ -547,6 +561,38 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool MDLocalImagesIsEnabled
{
get
{
return _mdLocalImagesEnabled;
}
set
{
if (_mdLocalImagesEnabledStateIsGPOConfigured)
{
return;
}
if (_mdLocalImagesEnabled != value)
{
_mdLocalImagesEnabled = value;
Settings.Properties.EnableMdLocalImages = value;
RaisePropertyChanged();
}
}
}
// Used to disable the toggle when the setting is forced by GPO (enabled or disabled).
public bool MDLocalImagesIsGPOConfigured
{
get
{
return _mdLocalImagesEnabledStateIsGPOConfigured;
}
}
public bool MonacoRenderIsEnabled
{
get