diff --git a/MacDown/Code/Document/MPAsset.h b/MacDown/Code/Document/MPAsset.h
index 6935dcd7..04dbe35e 100644
--- a/MacDown/Code/Document/MPAsset.h
+++ b/MacDown/Code/Document/MPAsset.h
@@ -35,8 +35,9 @@ extern NSString * const kMPMathJaxConfigType;
@interface MPScript : MPAsset
+ (instancetype)javaScriptWithURL:(NSURL *)url;
+- (NSString *)htmlForOption:(MPAssetOption)option nonce:(NSString *)nonce;
@end
@interface MPEmbeddedScript : MPScript
-@end
\ No newline at end of file
+@end
diff --git a/MacDown/Code/Document/MPAsset.m b/MacDown/Code/Document/MPAsset.m
index 4367c389..944f3359 100644
--- a/MacDown/Code/Document/MPAsset.m
+++ b/MacDown/Code/Document/MPAsset.m
@@ -21,6 +21,8 @@ @interface MPAsset ()
@property (strong) NSURL *url;
@property (copy, nonatomic) NSString *typeName;
@property (readonly) NSString *defaultTypeName;
+- (NSString *)htmlForOption:(MPAssetOption)option
+ extraContext:(NSDictionary *)extraContext;
@end
@@ -67,6 +69,12 @@ - (NSString *)templateForOption:(MPAssetOption)option
}
- (NSString *)htmlForOption:(MPAssetOption)option
+{
+ return [self htmlForOption:option extraContext:nil];
+}
+
+- (NSString *)htmlForOption:(MPAssetOption)option
+ extraContext:(NSDictionary *)extraContext
{
NSMutableDictionary *context =
[NSMutableDictionary dictionaryWithObject:self.typeName
@@ -89,6 +97,8 @@ - (NSString *)htmlForOption:(MPAssetOption)option
context[@"url"] = self.url.absoluteString;
break;
}
+ if (extraContext)
+ [context addEntriesFromDictionary:extraContext];
NSString *template = [self templateForOption:option];
if (!template || !context.count)
@@ -163,19 +173,32 @@ - (NSString *)templateForOption:(MPAssetOption)option
case MPAssetEmbedded:
if (self.url.isFileURL)
{
- template = (@"");
break;
}
// Non-file URLs fall-through to be treated as full links.
case MPAssetFullLink:
- template = (@"");
break;
}
return template;
}
+- (NSString *)htmlForOption:(MPAssetOption)option nonce:(NSString *)nonce
+{
+ NSDictionary *context = nil;
+ if (nonce.length)
+ {
+ NSString *attribute =
+ [NSString stringWithFormat:@" nonce=\"%@\"", nonce];
+ context = @{@"nonceAttribute": attribute};
+ }
+ return [self htmlForOption:option extraContext:context];
+}
+
@end
@@ -188,4 +211,11 @@ - (NSString *)htmlForOption:(MPAssetOption)option
return [super htmlForOption:option];
}
+- (NSString *)htmlForOption:(MPAssetOption)option nonce:(NSString *)nonce
+{
+ if (option == MPAssetFullLink)
+ option = MPAssetEmbedded;
+ return [super htmlForOption:option nonce:nonce];
+}
+
@end
diff --git a/MacDown/Code/Document/MPRenderer.m b/MacDown/Code/Document/MPRenderer.m
index 9aec3617..3802a5d1 100644
--- a/MacDown/Code/Document/MPRenderer.m
+++ b/MacDown/Code/Document/MPRenderer.m
@@ -248,12 +248,15 @@
NS_INLINE NSString *MPEscapeHTMLAttribute(NSString *value);
NS_INLINE NSString *MPEscapeHTMLText(NSString *value);
-NS_INLINE NSString *MPPreviewContentSecurityPolicy(void);
-NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken);
+NS_INLINE NSString *MPPreviewScriptNonce(void);
+NS_INLINE NSString *MPPreviewContentSecurityPolicy(NSString *scriptNonce);
+NS_INLINE NSString *MPPreviewHeadTags(
+ NSString *checkboxBridgeToken, NSString *scriptNonce);
NS_INLINE NSString *MPGetHTML(
NSString *title, NSString *headTags, NSString *body, NSArray *styles,
- MPAssetOption styleopt, NSArray *scripts, MPAssetOption scriptopt)
+ MPAssetOption styleopt, NSArray *scripts, MPAssetOption scriptopt,
+ NSString *scriptNonce)
{
NSMutableArray *styleTags = [NSMutableArray array];
NSMutableArray *scriptTags = [NSMutableArray array];
@@ -265,7 +268,7 @@
}
for (MPScript *script in scripts)
{
- NSString *s = [script htmlForOption:scriptopt];
+ NSString *s = [script htmlForOption:scriptopt nonce:scriptNonce];
if (s)
[scriptTags addObject:s];
}
@@ -484,26 +487,42 @@ NS_INLINE void MPFreeHTMLRenderer(hoedown_renderer *htmlRenderer)
return escaped;
}
-NS_INLINE NSString *MPPreviewContentSecurityPolicy(void)
+NS_INLINE NSString *MPPreviewScriptNonce(void)
+{
+ return [NSUUID.UUID.UUIDString
+ stringByReplacingOccurrencesOfString:@"-" withString:@""];
+}
+
+NS_INLINE NSString *MPPreviewContentSecurityPolicy(NSString *scriptNonce)
{
// MathJax 2.x relies on eval/new Function during startup, and bundled
// preview libraries inject inline styles while rendering annotated output.
- return @"default-src 'none'; "
- @"base-uri 'none'; "
- @"form-action 'none'; "
- @"object-src 'none'; "
- @"frame-src 'none'; "
- @"img-src data: file: http: https:; "
- @"media-src data: file: http: https:; "
- @"style-src 'self' 'unsafe-inline' file:; "
- @"font-src data: file:; "
- @"connect-src http: https:; "
- @"script-src 'self' file: https://cdnjs.cloudflare.com 'unsafe-eval'";
-}
-
-NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken)
-{
- NSString *csp = MPEscapeHTMLAttribute(MPPreviewContentSecurityPolicy());
+ // A per-render nonce keeps renderer-owned scripts working without allowing
+ // document-authored file:// script tags from raw Markdown HTML.
+ NSString *scriptPolicy = scriptNonce.length
+ ? [NSString stringWithFormat:
+ @"script-src 'nonce-%@' https://cdnjs.cloudflare.com 'unsafe-eval'",
+ scriptNonce]
+ : @"script-src 'none'";
+ return [NSString stringWithFormat:
+ @"default-src 'none'; "
+ @"base-uri 'none'; "
+ @"form-action 'none'; "
+ @"object-src 'none'; "
+ @"frame-src 'none'; "
+ @"img-src data: file: http: https:; "
+ @"media-src data: file: http: https:; "
+ @"style-src 'self' 'unsafe-inline' file:; "
+ @"font-src data: file:; "
+ @"connect-src 'none'; %@",
+ scriptPolicy];
+}
+
+NS_INLINE NSString *MPPreviewHeadTags(
+ NSString *checkboxBridgeToken, NSString *scriptNonce)
+{
+ NSString *csp =
+ MPEscapeHTMLAttribute(MPPreviewContentSecurityPolicy(scriptNonce));
NSString *token = MPEscapeHTMLAttribute(checkboxBridgeToken);
return [NSString stringWithFormat:
@"\n"
@@ -827,10 +846,12 @@ - (void)render
NSString *title = [self.dataSource rendererHTMLTitle:self];
if (!self.checkboxBridgeToken.length)
self.checkboxBridgeToken = NSUUID.UUID.UUIDString;
- NSString *headTags = MPPreviewHeadTags(self.checkboxBridgeToken);
+ NSString *scriptNonce = MPPreviewScriptNonce();
+ NSString *headTags =
+ MPPreviewHeadTags(self.checkboxBridgeToken, scriptNonce);
NSString *html = MPGetHTML(
title, headTags, body, self.stylesheets, MPAssetFullLink,
- self.scripts, MPAssetFullLink);
+ self.scripts, MPAssetFullLink, scriptNonce);
[delegate renderer:self didProduceHTMLOutput:html];
self.styleName = [delegate rendererStyleName:self];
@@ -902,7 +923,7 @@ - (NSString *)HTMLForExportWithStyles:(BOOL)withStyles
title = @"";
NSString *html = MPGetHTML(
title, nil, self.currentHtml, styles, stylesOption, scripts,
- scriptsOption);
+ scriptsOption, nil);
return html;
}
diff --git a/MacDownTests/MPAssetTests.m b/MacDownTests/MPAssetTests.m
index 856838f9..b8f79ae8 100644
--- a/MacDownTests/MPAssetTests.m
+++ b/MacDownTests/MPAssetTests.m
@@ -103,6 +103,31 @@ - (void)testJavaScript
@"JS, full link");
}
+- (void)testJavaScriptFullLinkWithNonce
+{
+ NSURL *url = [self.bundle URLForResource:@"test" withExtension:@"js"];
+ MPScript *script = [MPScript javaScriptWithURL:url];
+
+ NSString *tag =
+ @"";
+ tag = [NSString stringWithFormat:tag, url.absoluteString];
+ XCTAssertEqualObjects([script htmlForOption:MPAssetFullLink nonce:@"abc123"],
+ tag, @"JS full link with nonce");
+}
+
+- (void)testEmbeddedScriptWithNonce
+{
+ NSURL *url = [self.bundle URLForResource:@"test" withExtension:@"js"];
+ MPEmbeddedScript *script =
+ [MPEmbeddedScript assetWithURL:url andType:kMPMathJaxConfigType];
+
+ NSString *tag = @"";
+ XCTAssertEqualObjects([script htmlForOption:MPAssetFullLink nonce:@"abc123"],
+ tag, @"Embedded JS full link forced embedded with nonce");
+}
+
- (void)testEmbedded
{
NSURL *url = [self.bundle URLForResource:@"test" withExtension:@"js"];
diff --git a/MacDownTests/MPRendererEdgeCaseTests.m b/MacDownTests/MPRendererEdgeCaseTests.m
index 63a2909f..5c3389dd 100644
--- a/MacDownTests/MPRendererEdgeCaseTests.m
+++ b/MacDownTests/MPRendererEdgeCaseTests.m
@@ -150,14 +150,42 @@ - (void)testPreviewRenderIncludesContentSecurityPolicyAndCheckboxToken
XCTAssertNotNil(html, @"Preview render should produce HTML");
XCTAssertTrue([html containsString:@"Content-Security-Policy"],
@"Preview HTML should include a CSP meta tag");
- XCTAssertTrue([html containsString:@"script-src 'self' file: https://cdnjs.cloudflare.com 'unsafe-eval'"],
- @"CSP should whitelist only bundled scripts and the MathJax CDN");
+ XCTAssertTrue([html containsString:@"script-src 'nonce-"],
+ @"CSP should allow nonce-bearing preview scripts");
+ XCTAssertFalse([html containsString:@"script-src 'self' file:"],
+ @"CSP should not allow document-supplied file scripts");
+ XCTAssertFalse([html containsString:@"connect-src http: https:"],
+ @"CSP should not allow arbitrary script exfiltration");
+ XCTAssertTrue([html containsString:@"";
+ self.dataSource.markdown = script;
+
+ [self.renderer parseMarkdown:self.dataSource.markdown];
+ [self.renderer render];
+
+ NSString *html = self.delegate.lastHTML;
+ XCTAssertNotNil(html, @"Preview render should produce HTML");
+ XCTAssertTrue([html containsString:script],
+ @"Raw Markdown HTML may still be rendered into the preview");
+ XCTAssertFalse([html containsString:@"script-src 'self' file:"],
+ @"CSP should not allow document-supplied file scripts");
+ XCTAssertFalse([html containsString:@"connect-src http: https:"],
+ @"CSP should not allow arbitrary script exfiltration");
+ XCTAssertFalse([html containsString:
+ @"file:///tmp/macdown-preview-attack.js\" nonce=\""],
+ @"Document-supplied script tags should not receive a nonce");
+}
+
- (void)testPreviewRenderKeepsCheckboxBridgeTokenStableAcrossRenders
{
self.renderer.rendererFlags = HOEDOWN_HTML_USE_TASK_LIST;