diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 8434397411..86129ca3ce 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -966,6 +966,7 @@ lng LOADFROMFILE LOBYTE localappdata +localmdimages localpackage LOCALSYSTEM LOCATIONCHANGE diff --git a/src/common/FilePreviewCommon/HTMLParsingExtension.cs b/src/common/FilePreviewCommon/HTMLParsingExtension.cs index b0dae4dcbe..a48ecb274a 100644 --- a/src/common/FilePreviewCommon/HTMLParsingExtension.cs +++ b/src/common/FilePreviewCommon/HTMLParsingExtension.cs @@ -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 /// public string FilePath { get; set; } + /// + /// 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. + /// + public string? AllowedBasePath { get; set; } + + /// + /// Gets or sets a value indicating whether local images should be rendered. + /// + 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; + } + + /// + /// 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. + /// + /// Image URL from the markdown document. + /// Directory containing the markdown file; relative URLs resolve against it. + /// Base path the resolved path must be contained in. Falls back to if empty. + /// The rewritten virtual host URL on success. + /// True if the URL is a contained local image and was set. + 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; + } + } + + /// + /// 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. + /// + /// The request URL, expected on the localmdimages virtual host. + /// Base path the resolved file must be contained in. + /// The validated absolute file path on success. + /// True if the URL maps to a contained file path and was set. + /// Each path component is inspected for reparse points, so a path that does not + /// exist or cannot be read fails closed and returns false. + 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; + } + /// 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(); + } } } } diff --git a/src/common/FilePreviewCommon/MarkdownHelper.cs b/src/common/FilePreviewCommon/MarkdownHelper.cs index 2003df3340..3392676b7a 100644 --- a/src/common/FilePreviewCommon/MarkdownHelper.cs +++ b/src/common/FilePreviewCommon/MarkdownHelper.cs @@ -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 = $""; + var htmlHeader = (theme == "dark" ? HtmlDarkHeader : HtmlLightHeader).Insert("".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, + @"(]*?)\s+srcset\s*=\s*(?:""[^""]*""|'[^']*'|[^\s>]+)", + m => + { + imagesBlockedCallBack(); + return m.Groups[1].Value; + }, + RegexOptions.IgnoreCase); + + // Sanitize src on raw HTML 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, + @"(]*?\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; } diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 8d6844c3df..b2bccf29a5 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -60,6 +60,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredMarkdownPreviewEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredMarkdownLocalImagesEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredMarkdownLocalImagesEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredMonacoPreviewEnabledValue() { return static_cast(powertoys_gpo::getConfiguredMonacoPreviewEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 616b523e16..3a29982f74 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -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(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 33d6821673..5b5bed82fa 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -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(); diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index a7ae4bb00f..5434b6df82 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -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 } diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index bc71d9229f..43de800243 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -31,6 +31,7 @@ + @@ -57,6 +58,9 @@ + + + @@ -222,6 +226,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index f7d8068c2e..9b1c4f9534 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -14,6 +14,7 @@ General settings New+ Deprecated policies + File Explorer Preview PowerToys version 0.64.0 or later PowerToys version 0.68.0 or later @@ -38,6 +39,7 @@ PowerToys version 0.98.0 or later PowerToys version 0.99.0 or later PowerToys version 0.100.0 or later + PowerToys version 0.101.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 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 File Locksmith: Configure enabled state SVG file preview: Configure enabled state Markdown file preview: Configure enabled state + Markdown preview: Show images from local and network sources + 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). + Source code file preview: Configure enabled state PDF file preview: Configure enabled state Gcode file preview: Configure enabled state diff --git a/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj b/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj index 6dc4006e53..11f6291171 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj +++ b/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj @@ -62,6 +62,7 @@ + diff --git a/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandlerControl.cs b/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandlerControl.cs index 875e1e98d7..e8b4beede0 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandlerControl.cs +++ b/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandlerControl.cs @@ -56,6 +56,10 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown /// private bool _infoBarDisplayed; + private string _markdownDirectory; + private string _allowedBasePath; + private bool _allowLocalImages; + /// /// Gets the path of the current assembly. /// @@ -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 } } + /// + /// Returns the HTTP Content-Type for an image file based on its extension. + /// + /// Path of the image file. + /// The content type string. + 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", + }; + } + /// /// Callback when image is blocked by extension. /// diff --git a/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.Designer.cs b/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.Designer.cs index c15e32ce06..b37dec1397 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.Designer.cs +++ b/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.Designer.cs @@ -78,6 +78,15 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown.Properties { } } + /// + /// 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.. + /// + internal static string RemoteImagesBlockedInfoText { + get { + return ResourceManager.GetString("RemoteImagesBlockedInfoText", resourceCulture); + } + } + /// /// Looks up a localized string for an error when Gpo has the utility disabled. /// diff --git a/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.resx b/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.resx index 7c4d1b6065..24fd4ed866 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.resx +++ b/src/modules/previewpane/MarkdownPreviewHandler/Properties/Resources.resx @@ -125,6 +125,10 @@ The markdown could not be preview due to an internal error. This text is displayed if markdown fails to preview + + Some online images have been blocked. Only local images from the document's folder and images on the same network share are shown. + This text is displayed when local images are enabled but the document contains remote/online image URLs that were blocked. + Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator. GPO stands for the Windows Group Policy Object feature. diff --git a/src/modules/previewpane/MarkdownPreviewHandler/Settings.cs b/src/modules/previewpane/MarkdownPreviewHandler/Settings.cs index a69808d2ed..b8a3a83e79 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/Settings.cs +++ b/src/modules/previewpane/MarkdownPreviewHandler/Settings.cs @@ -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; + + /// + /// Returns whether local images should be displayed in the Markdown preview. + /// GPO policy takes precedence over user setting. + /// + 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.ModuleName).Properties.EnableMdLocalImages; + } + catch (FileNotFoundException) + { + return false; + } + } } } diff --git a/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/GPO-TESTING.md b/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/GPO-TESTING.md new file mode 100644 index 0000000000..ac896541e1 --- /dev/null +++ b/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/GPO-TESTING.md @@ -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 diff --git a/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/images/test.png b/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/images/test.png new file mode 100644 index 0000000000..6ae44344c5 Binary files /dev/null and b/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/images/test.png differ diff --git a/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/test-local-images.md b/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/test-local-images.md new file mode 100644 index 0000000000..80a4ad6d12 --- /dev/null +++ b/src/modules/previewpane/MarkdownPreviewHandler/TestFiles/test-local-images.md @@ -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) + +HTML img tag test + +## 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: + + + +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) diff --git a/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HTMLParsingExtensionTest.cs b/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HTMLParsingExtensionTest.cs index d8fe7dc9bc..21e9484806 100644 --- a/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HTMLParsingExtensionTest.cs +++ b/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HTMLParsingExtensionTest.cs @@ -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 = "

\"text\"

\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("")] + [DataRow("")] + [DataRow("")] + [DataRow("\"x\"")] + 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("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + 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 = ""; + + 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("", "base64")] + [DataRow("", "example.com")] + [DataRow("", "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(" { 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 = "

\"text\"

\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 = "

\"text\"

\n"; + Assert.AreEqual(expected, html); + } } } diff --git a/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs b/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs index b99b19aae7..5ada3c53b9 100644 --- a/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs @@ -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")] diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml index 85f27ce030..81999a16ba 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml @@ -132,7 +132,7 @@ - - + + + + + + + + + + + + + .md, .markdown, .mdown, .mkdn, .mkd, .mdwn, .mdtxt, .mdtext {Locked}
+ + Show local images + + + Display images stored in the Markdown document's folder, including images on the same network share, in the preview. Remote/online images remain blocked. + Source code files (Monaco) File type, do not translate diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs index 75c22dd855..2f26a4bef7 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs @@ -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