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;