Repository navigation
Expand file tree
/
Copy pathHTMLParsingExtension.cs
More file actions
309 lines (278 loc) · 12.5 KB
/
Copy pathHTMLParsingExtension.cs
File metadata and controls
309 lines (278 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Markdig;
using Markdig.Extensions.Figures;
using Markdig.Extensions.Tables;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Microsoft.PowerToys.FilePreviewCommon
{
/// <summary>
/// Callback if extension blocks external images.
/// </summary>
public delegate void ImagesBlockedCallBack();
/// <summary>
/// Markdig Extension to process html nodes in markdown AST.
/// </summary>
public class HTMLParsingExtension : IMarkdownExtension
{
/// <summary>
/// Callback if extension blocks external images.
/// </summary>
private readonly ImagesBlockedCallBack imagesBlockedCallBack;
/// <summary>
/// Initializes a new instance of the <see cref="HTMLParsingExtension"/> class.
/// </summary>
/// <param name="imagesBlockedCallBack">Callback function if image is blocked by extension.</param>
/// <param name="filePath">Absolute path of markdown file.</param>
public HTMLParsingExtension(ImagesBlockedCallBack imagesBlockedCallBack, string filePath = "")
{
this.imagesBlockedCallBack = imagesBlockedCallBack;
FilePath = filePath;
}
/// <summary>
/// Gets or sets path to directory containing markdown file.
/// </summary>
public string FilePath { get; set; }
/// <summary>
/// Gets or sets the base path used for path validation and relative URL computation.
/// For local files this equals FilePath. For UNC paths this is the share root.
/// </summary>
public string? AllowedBasePath { get; set; }
/// <summary>
/// Gets or sets a value indicating whether local images should be rendered.
/// </summary>
public bool AllowLocalImages { get; set; }
private static bool IsLocalImage([NotNullWhen(true)] string? url)
{
if (string.IsNullOrEmpty(url))
{
return false;
}
// Reject any URI-like scheme (http:, https:, data:, javascript:, file:, ...).
// A colon is only permitted as part of a drive path like "C:\" or "C:/".
int colonIndex = url.IndexOf(':');
if (colonIndex >= 0)
{
bool isDrivePath = colonIndex == 1 && char.IsLetter(url[0]) && url.Length > 2 && (url[2] == '\\' || url[2] == '/');
if (!isDrivePath)
{
return false;
}
}
return true;
}
/// <summary>
/// Validates that a local image URL resolves to a path inside the allowed base path and
/// computes the corresponding virtual host URL. Returns false for remote URLs, URI schemes
/// (data:, javascript:, file:, ...), path traversal outside the base path and malformed paths.
/// </summary>
/// <param name="url">Image URL from the markdown document.</param>
/// <param name="markdownDirectory">Directory containing the markdown file; relative URLs resolve against it.</param>
/// <param name="allowedBasePath">Base path the resolved path must be contained in. Falls back to <paramref name="markdownDirectory"/> if empty.</param>
/// <param name="virtualUrl">The rewritten virtual host URL on success.</param>
/// <returns>True if the URL is a contained local image and <paramref name="virtualUrl"/> was set.</returns>
public static bool TryGetLocalImageVirtualUrl(string? url, string markdownDirectory, string? allowedBasePath, [NotNullWhen(true)] out string? virtualUrl)
{
virtualUrl = null;
if (!IsLocalImage(url))
{
return false;
}
try
{
string basePath = Path.GetFullPath(string.IsNullOrEmpty(allowedBasePath) ? markdownDirectory : allowedBasePath);
string resolvedPath = Path.GetFullPath(Path.Combine(markdownDirectory, url));
string relativePath = Path.GetRelativePath(basePath, resolvedPath);
if (relativePath == "." || relativePath == ".." ||
relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
relativePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) ||
Path.IsPathRooted(relativePath))
{
return false;
}
// Reserved characters in a filename (#, %, ...) are not valid in a URL path, so
// escape it while keeping the directory separators intact. TryResolveVirtualUrl
// unescapes symmetrically when the request comes back.
string escapedPath = Uri.EscapeDataString(relativePath.Replace('\\', '/')).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
virtualUrl = "https://localmdimages/" + escapedPath;
return true;
}
catch (ArgumentException)
{
return false;
}
catch (PathTooLongException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
}
/// <summary>
/// Resolves a virtual host image request URL back to a file path and validates that it is
/// contained in the allowed base path. Used when serving the image bytes for a WebView2
/// resource request. Returns false for foreign hosts, empty paths, path traversal outside
/// the base path (including percent-encoded traversal) and malformed paths.
/// </summary>
/// <param name="requestUri">The request URL, expected on the localmdimages virtual host.</param>
/// <param name="allowedBasePath">Base path the resolved file must be contained in.</param>
/// <param name="resolvedPath">The validated absolute file path on success.</param>
/// <returns>True if the URL maps to a contained file path and <paramref name="resolvedPath"/> was set.</returns>
/// <remarks>Each path component is inspected for reparse points, so a path that does not
/// exist or cannot be read fails closed and returns false.</remarks>
public static bool TryResolveVirtualUrl(string? requestUri, string? allowedBasePath, [NotNullWhen(true)] out string? resolvedPath)
{
resolvedPath = null;
if (string.IsNullOrEmpty(requestUri) || string.IsNullOrEmpty(allowedBasePath))
{
return false;
}
try
{
var uri = new Uri(requestUri);
if (!string.Equals(uri.Host, "localmdimages", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string relativePath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
if (relativePath.Length == 0)
{
return false;
}
string basePath = Path.GetFullPath(allowedBasePath);
string fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
string containmentCheck = Path.GetRelativePath(basePath, fullPath);
if (containmentCheck == "." || containmentCheck == ".." ||
containmentCheck.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
containmentCheck.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) ||
Path.IsPathRooted(containmentCheck))
{
return false;
}
// The check above proves only lexical containment. A junction or symbolic link
// anywhere below the base path can still redirect the read outside it, so walk the
// resolved path back up to the base and reject any reparse point on the way. The
// base path itself is not checked: that is the document's own location.
string baseComparand = TrimTrailingSeparators(basePath);
string current = fullPath;
while (!string.Equals(TrimTrailingSeparators(current), baseComparand, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
return false;
}
string? parent = Path.GetDirectoryName(current);
if (string.IsNullOrEmpty(parent) || parent.Length >= current.Length)
{
return false;
}
current = parent;
}
resolvedPath = fullPath;
return true;
}
catch (ArgumentException)
{
return false;
}
catch (UriFormatException)
{
return false;
}
catch (PathTooLongException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static string TrimTrailingSeparators(string path)
{
string trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// Keep the separator for roots such as "C:\", where trimming changes the meaning.
return trimmed.Length == 0 || trimmed.EndsWith(Path.VolumeSeparatorChar) ? path : trimmed;
}
/// <inheritdoc/>
public void Setup(MarkdownPipelineBuilder pipeline)
{
if (pipeline != null)
{
// Make sure we don't have a delegate twice
pipeline.DocumentProcessed -= PipelineOnDocumentProcessed;
pipeline.DocumentProcessed += PipelineOnDocumentProcessed;
}
}
/// <inheritdoc/>
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
}
/// <summary>
/// Process nodes in markdown AST.
/// </summary>
/// <param name="document">Markdown Document.</param>
public void PipelineOnDocumentProcessed(MarkdownDocument document)
{
foreach (var node in document.Descendants())
{
if (node is Block)
{
if (node is Table)
{
node.GetAttributes().AddClass("table table-striped table-bordered");
}
else if (node is QuoteBlock)
{
node.GetAttributes().AddClass("blockquote");
}
else if (node is Figure)
{
node.GetAttributes().AddClass("figure");
}
else if (node is FigureCaption)
{
node.GetAttributes().AddClass("figure-caption");
}
}
else if (node is Inline)
{
if (node is LinkInline link)
{
if (link.IsImage)
{
if (AllowLocalImages && TryGetLocalImageVirtualUrl(link.Url, FilePath, AllowedBasePath, out string? virtualUrl))
{
link.Url = virtualUrl;
link.GetAttributes().AddClass("img-fluid");
}
else
{
link.Url = "#";
link.GetAttributes().AddClass("img-fluid");
imagesBlockedCallBack();
}
}
}
}
}
}
}
}