iOS web browser with a focus on security and privacy

URLInterceptor: fix CSP header munging with script-src nonce

When a site has an existing CSP with script-src but has things in it
like 'unsafe-inline', when we prepend our nonced version of the
injected script, the browser effectively ignores all other values in
the directive and requires that all of them have nonces. Since none
of them do, all of the 'unsafe-inline' scripts on the page break.

Fixes images loading on a medium.com page which has such a CSP.

+65 -18
+32 -5
Endless Tests/URLInterceptor_Tests.m
··· 19 19 - (void)testCSPHeaderInjection 20 20 { 21 21 NSString *inp = @"default-src 'self'; connect-src 'self'; font-src 'self' data:; frame-src https://twitter.com https://*.twitter.com https://*.twimg.com twitter: https://www.google.com https://5415703.fls.doubleclick.net; frame-ancestors https://*.twitter.com; img-src https://twitter.com https://*.twitter.com https://*.twimg.com https://maps.google.com https://www.google-analytics.com https://stats.g.doubleclick.net https://www.google.com https://ad.doubleclick.net data:; media-src https://*.twitter.com https://*.twimg.com https://*.cdn.vine.co; object-src 'self'; script-src 'unsafe-inline' 'unsafe-eval' https://*.twitter.com https://*.twimg.com https://www.google.com https://www.google-analytics.com https://stats.g.doubleclick.net; style-src 'unsafe-inline' https://*.twitter.com https://*.twimg.com; report-uri https://twitter.com/i/csp_report?a=O5SWEZTPOJQWY3A%3D&ro=false;"; 22 - 23 - NSString *outp = [URLInterceptor prependDirectivesIfExisting:@{ @"frame-src": @"endless:", @"child-src": @"endless:"} inCSPHeader:inp]; 24 - 22 + NSString *outp = [URLInterceptor prependDirectivesIfExisting:@{ 23 + @"frame-src": @[ @"endless:", @"endless:" ], 24 + @"child-src": @[ @"endless:", @"endless:" ] 25 + } inCSPHeader:inp]; 25 26 XCTAssertEqualObjects(outp, @"connect-src 'self'; default-src 'self'; font-src 'self' data:; frame-ancestors https://*.twitter.com; frame-src endless: https://twitter.com https://*.twitter.com https://*.twimg.com twitter: https://www.google.com https://5415703.fls.doubleclick.net; img-src https://twitter.com https://*.twitter.com https://*.twimg.com https://maps.google.com https://www.google-analytics.com https://stats.g.doubleclick.net https://www.google.com https://ad.doubleclick.net data:; media-src https://*.twitter.com https://*.twimg.com https://*.cdn.vine.co; object-src 'self'; report-uri https://twitter.com/i/csp_report?a=O5SWEZTPOJQWY3A%3D&ro=false; script-src 'unsafe-inline' 'unsafe-eval' https://*.twitter.com https://*.twimg.com https://www.google.com https://www.google-analytics.com https://stats.g.doubleclick.net; style-src 'unsafe-inline' https://*.twitter.com https://*.twimg.com;"); 26 27 } 27 28 28 29 - (void)testCSPHeaderNoneRemoval 29 30 { 30 31 /* make sure 'none' is removed and our value is used */ 31 - NSString *outp = [URLInterceptor prependDirectivesIfExisting:@{ @"frame-src": @"endless:" } inCSPHeader:@"blah-src 'self';frame-src 'none' ; blah2-src 'none';"]; 32 + NSString *outp = [URLInterceptor prependDirectivesIfExisting:@{ 33 + @"frame-src": @[ @"endless:", @"endless:" ] 34 + } inCSPHeader:@"blah-src 'self';frame-src 'none' ; blah2-src 'none';"]; 32 35 XCTAssertEqualObjects(outp, @"blah-src 'self'; blah2-src 'none'; frame-src endless:;"); 33 36 } 34 37 35 38 - (void)testCSPHeaderSelectiveInclusion 36 39 { 37 40 /* only change directives if they existed in the original policy */ 38 - NSString *outp = [URLInterceptor prependDirectivesIfExisting:@{ @"child-src": @"endlessipc:", @"frame-src": @"endlessipc:", @"script-src" : @"'none'" } inCSPHeader:@"referrer always;"]; 41 + NSString *outp = [URLInterceptor prependDirectivesIfExisting:@{ 42 + @"child-src": @[ @"endlessipc:", @"endlessipc:" ], 43 + @"frame-src": @[ @"endlessipc:", @"endlessipc:" ], 44 + @"script-src" : @[ @"'none'", @"'none'" ] 45 + } inCSPHeader:@"referrer always;"]; 39 46 XCTAssertEqualObjects(outp, @"referrer always;"); 47 + } 48 + 49 + - (void)testCSPHeaderSelectiveNonce 50 + { 51 + NSDictionary *wantedDirectives = @{ 52 + @"child-src": @[ @"endlessipc:", @"endlessipc:" ], 53 + @"default-src" : @[ @"endlessipc:", @"'nonce-blah' endlessipc:" ], 54 + @"frame-src": @[ @"endlessipc:", @"endlessipc:" ], 55 + @"script-src" : @[ @"", @"'nonce-blah'" ], 56 + }; 57 + 58 + /* only prepend nonced values if the original directive contained '{sha256|sha384|sha512|nonce}-.*' */ 59 + NSString *inp = @"default-src 'self'; script-src https://cdn.example.com 'sha512-abcdefghijkl'; style-src 'unsafe-inline'"; 60 + NSString *outp = [URLInterceptor prependDirectivesIfExisting:wantedDirectives inCSPHeader:inp]; 61 + XCTAssertEqualObjects(outp, @"default-src endlessipc: 'self'; script-src 'nonce-blah' https://cdn.example.com 'sha512-abcdefghijkl'; style-src 'unsafe-inline';"); 62 + 63 + /* when it doesn't use nonces, use the non-nonced version */ 64 + NSString *inp2 = @"default-src 'self'; connect-src https://localhost https://*.instapaper.com https://*.stripe.com https://getpocket.com https://m.signalvnoise.com https://*.m.signalvnoise.com https://*.medium.com https://medium.com https://*.medium.com https://*.algolia.net https://cdn-static-1.medium.com https://dnqgz544uhbo8.cloudfront.net https://*.lightstep.com 'self'; font-src data: https://*.amazonaws.com https://*.medium.com https://*.gstatic.com https://dnqgz544uhbo8.cloudfront.net https://use.typekit.net https://cdn-static-1.medium.com 'self'; frame-src chromenull: https: webviewprogressproxy: medium: 'self'; img-src blob: data: https: 'self'; media-src https://*.cdn.vine.co https://d1fcbxp97j4nb2.cloudfront.net https://d262ilb51hltx0.cloudfront.net https://medium2.global.ssl.fastly.net https://*.medium.com https://gomiro.medium.com https://miro.medium.com https://pbs.twimg.com 'self'; object-src 'self'; script-src 'unsafe-eval' 'unsafe-inline' about: https: 'self'; style-src 'unsafe-inline' data: https: 'self'; report-uri https://csp.medium.com"; 65 + NSString *outp2 = [URLInterceptor prependDirectivesIfExisting:wantedDirectives inCSPHeader:inp2]; 66 + XCTAssertEqualObjects(outp2, @"connect-src https://localhost https://*.instapaper.com https://*.stripe.com https://getpocket.com https://m.signalvnoise.com https://*.m.signalvnoise.com https://*.medium.com https://medium.com https://*.medium.com https://*.algolia.net https://cdn-static-1.medium.com https://dnqgz544uhbo8.cloudfront.net https://*.lightstep.com 'self'; default-src endlessipc: 'self'; font-src data: https://*.amazonaws.com https://*.medium.com https://*.gstatic.com https://dnqgz544uhbo8.cloudfront.net https://use.typekit.net https://cdn-static-1.medium.com 'self'; frame-src endlessipc: chromenull: https: webviewprogressproxy: medium: 'self'; img-src blob: data: https: 'self'; media-src https://*.cdn.vine.co https://d1fcbxp97j4nb2.cloudfront.net https://d262ilb51hltx0.cloudfront.net https://medium2.global.ssl.fastly.net https://*.medium.com https://gomiro.medium.com https://miro.medium.com https://pbs.twimg.com 'self'; object-src 'self'; report-uri https://csp.medium.com; script-src 'unsafe-eval' 'unsafe-inline' about: https: 'self'; style-src 'unsafe-inline' data: https: 'self';"); 40 67 } 41 68 42 69 @end
+1 -1
Endless.xcodeproj/project.pbxproj
··· 321 321 isa = PBXGroup; 322 322 children = ( 323 323 01D741291A45EDD1007B7033 /* CookieJar_Tests.m */, 324 - 01D42C3C1E0A4FE400566022 /* URLInterceptor_Tests.m */, 325 324 01F7CB4A1A526B9C00F42B73 /* HSTSCache_Tests.m */, 326 325 018333DB1A35727C00670CD1 /* HTTPSEverywhere_Tests.m */, 327 326 0141D9691E0C66F1003472BC /* LocalNetworkChecker_Tests.m */, 328 327 01F2AE411B827BC200D5651A /* SSLCertificate_Tests.m */, 329 328 01F879401A4112E500A63654 /* URLBlocker_Tests.m */, 329 + 01D42C3C1E0A4FE400566022 /* URLInterceptor_Tests.m */, 330 330 018333D91A35727C00670CD1 /* Supporting Files */, 331 331 ); 332 332 path = "Endless Tests";
+32 -12
Endless/URLInterceptor.m
··· 118 118 } 119 119 120 120 for (NSString *newDir in [directives allKeys]) { 121 - NSString *newval = [directives objectForKey:newDir]; 121 + NSArray *newvals = [directives objectForKey:newDir]; 122 122 NSString *curval = [curDirectives objectForKey:newDir]; 123 123 if (curval) { 124 + NSString *newval = [newvals objectAtIndex:0]; 125 + 124 126 /* 125 - * CSP spec says if 'none' is encountered to ignore anything else, so if 126 - * 'none' is there, just replace it with newval rather than prepending 127 + * If none of the existing values for this directive have a nonce or hash, 128 + * then inserting our value with a nonce will cause the directive to become 129 + * strict, so "'nonce-abcd' 'self' 'unsafe-inline'" causes the browser to 130 + * ignore 'self' and 'unsafe-inline', requiring that all scripts have a 131 + * nonce or hash. Since the site would probably only ever have nonce values 132 + * in its <script> tags if it was in the CSP policy, only include our nonce 133 + * value if the CSP policy already has them. 127 134 */ 128 - if (![curval containsString:@"'none'"]) 129 - newval = [NSString stringWithFormat:@"%@ %@", newval, curval]; 135 + if ([curval containsString:@"'nonce-"] || [curval containsString:@"'sha"]) 136 + newval = [newvals objectAtIndex:1]; 137 + 138 + if ([curval containsString:@"'none'"]) { 139 + /* 140 + * CSP spec says if 'none' is encountered to ignore anything else, 141 + * so if 'none' is there, just replace it with newval rather than 142 + * prepending. 143 + */ 144 + } else { 145 + if ([newval isEqualToString:@""]) 146 + newval = curval; 147 + else 148 + newval = [NSString stringWithFormat:@"%@ %@", newval, curval]; 149 + } 130 150 131 151 [curDirectives setObject:newval forKey:newDir]; 132 152 } ··· 366 386 } 367 387 368 388 /* rewrite or inject Content-Security-Policy (and X-Webkit-CSP just in case) headers */ 369 - NSString *CSPheader; 389 + NSString *CSPheader = nil; 370 390 NSString *CSPmode = [self.originHostSettings setting:HOST_SETTINGS_KEY_CSP]; 371 391 372 392 if ([CSPmode isEqualToString:HOST_SETTINGS_CSP_STRICT]) 373 393 CSPheader = @"media-src 'none'; object-src 'none'; connect-src 'none'; font-src 'none'; sandbox allow-forms allow-top-navigation; style-src 'unsafe-inline' *; report-uri;"; 374 394 else if ([CSPmode isEqualToString:HOST_SETTINGS_CSP_BLOCK_CONNECT]) 375 395 CSPheader = @"connect-src 'none'; media-src 'none'; object-src 'none'; report-uri;"; 376 - else 377 - CSPheader = nil; 378 396 379 397 NSString *curCSP = [self caseInsensitiveHeader:@"content-security-policy" inResponse:response]; 380 398 ··· 384 402 385 403 NSMutableDictionary *mHeaders = [[NSMutableDictionary alloc] initWithDictionary:[response allHeaderFields]]; 386 404 405 + /* don't bother rewriting with the header if we don't want a restrictive one (CSPheader) and the site doesn't have one (curCSP) */ 387 406 if (CSPheader != nil || curCSP != nil) { 388 407 BOOL foundCSP = false; 389 408 409 + /* directives and their values (normal and nonced versions) to prepend */ 390 410 NSDictionary *wantedDirectives = @{ 391 - @"child-src": @"endlessipc:", 392 - @"default-src" : [NSString stringWithFormat:@"'nonce-%@' endlessipc:", [self cspNonce]], 393 - @"frame-src": @"endlessipc:", 394 - @"script-src" : [NSString stringWithFormat:@"'nonce-%@'", [self cspNonce]], 411 + @"child-src": @[ @"endlessipc:", @"endlessipc:" ], 412 + @"default-src" : @[ @"endlessipc:", [NSString stringWithFormat:@"'nonce-%@' endlessipc:", [self cspNonce]] ], 413 + @"frame-src": @[ @"endlessipc:", @"endlessipc:" ], 414 + @"script-src" : @[ @"", [NSString stringWithFormat:@"'nonce-%@'", [self cspNonce]] ], 395 415 }; 396 416 397 417 for (id h in [mHeaders allKeys]) {