Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion MacDown/Code/Document/MPAsset.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
@end
34 changes: 32 additions & 2 deletions MacDown/Code/Document/MPAsset.m
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -163,19 +173,32 @@ - (NSString *)templateForOption:(MPAssetOption)option
case MPAssetEmbedded:
if (self.url.isFileURL)
{
template = (@"<script type=\"{{ typeName }}\">\n"
template = (@"<script type=\"{{ typeName }}\"{{{ nonceAttribute }}}>\n"
@"{{{ content }}}\n</script>");
break;
}
// Non-file URLs fall-through to be treated as full links.
case MPAssetFullLink:
template = (@"<script type=\"{{ typeName }}\" src=\"{{ url }}\">"
template = (@"<script type=\"{{ typeName }}\"{{{ nonceAttribute }}} "
@"src=\"{{ url }}\">"
@"</script>");
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


Expand All @@ -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
69 changes: 45 additions & 24 deletions MacDown/Code/Document/MPRenderer.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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];
}
Expand Down Expand Up @@ -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:
@"<meta http-equiv=\"Content-Security-Policy\" content=\"%@\">\n"
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -902,7 +923,7 @@ - (NSString *)HTMLForExportWithStyles:(BOOL)withStyles
title = @"";
NSString *html = MPGetHTML(
title, nil, self.currentHtml, styles, stylesOption, scripts,
scriptsOption);
scriptsOption, nil);
return html;
}

Expand Down
25 changes: 25 additions & 0 deletions MacDownTests/MPAssetTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
@"<script type=\"text/javascript\" nonce=\"abc123\" "
@"src=\"%@\"></script>";
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 = @"<script type=\"text/x-mathjax-config\" nonce=\"abc123\">\n"
@"console.log('test');\n</script>";
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"];
Expand Down
32 changes: 30 additions & 2 deletions MacDownTests/MPRendererEdgeCaseTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 &#39;self&#39; file: https://cdnjs.cloudflare.com &#39;unsafe-eval&#39;"],
@"CSP should whitelist only bundled scripts and the MathJax CDN");
XCTAssertTrue([html containsString:@"script-src &#39;nonce-"],
@"CSP should allow nonce-bearing preview scripts");
XCTAssertFalse([html containsString:@"script-src &#39;self&#39; 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:@"<script type=\"text/javascript\" nonce=\""],
@"Renderer-owned scripts should carry the preview nonce");
XCTAssertTrue([html containsString:@"name=\"macdown-checkbox-token\""],
@"Preview HTML should include a checkbox bridge token");
XCTAssertTrue(self.renderer.checkboxBridgeToken.length > 0,
@"Renderer should expose the active checkbox bridge token");
}

- (void)testPreviewCSPBlocksDocumentSuppliedFileScripts
{
NSString *script =
@"<script src=\"file:///tmp/macdown-preview-attack.js\"></script>";
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 &#39;self&#39; 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;
Expand Down
Loading