diff --git a/App/Web Process Bundle Bridge/SBRProcessBundleBridge.m b/App/Web Process Bundle Bridge/SBRProcessBundleBridge.m index f5f08bb..734773d 100644 --- a/App/Web Process Bundle Bridge/SBRProcessBundleBridge.m +++ b/App/Web Process Bundle Bridge/SBRProcessBundleBridge.m @@ -11,6 +11,7 @@ #import "Hacks.h" #import +#import #import #import @@ -19,10 +20,28 @@ #import #import #import +#import #define WKUserStyleSheet id #define WKUserStyleSheetEncodedClassName "X1dLVXNlclN0eWxlU2hlZXQ=" +NSArray *CommonCDNList(void) { + static dispatch_once_t onceToken; + static NSArray *commonCDNList = nil; + dispatch_once(&onceToken, ^{ + commonCDNList = @[ + @"cdn.jsdelivr.net", + @"fsdn.net", + @"cdnjs.com", + @"osscdn.com", + @"code.jquery.com", + @"bootstrapcdn.com" + ]; + }); + + return commonCDNList; +} + @interface StyleSheet : NSObject @property (nonatomic, readonly, copy) NSString *source; @property (nonatomic, readonly, copy) NSURL *baseURL; @@ -31,6 +50,9 @@ - (instancetype)initWithSource:(NSString *)source forMainFrameOnly:(BOOL)forMainFrameOnly; @end +// Note: We no longer proxy http/https via WKURLSchemeHandler; we use +// WebKit content blocking rules instead. + @interface WKUserContentController (Private) - (void)__addUserStyleSheet:(WKUserStyleSheet)userStyleSheet; - (void)__removeUserStyleSheet:(WKUserStyleSheet)userStyleSheet; @@ -68,6 +90,13 @@ #define LOG_DEBUG(format, ...) os_log_debug(_log, format, ##__VA_ARGS__) #define LOG_ERROR(format, ...) os_log_error(_log, format, ##__VA_ARGS__) +static os_log_t _log; + +__attribute__((constructor)) +static void initialize_log(void) { + _log = os_log_create("net.buzzert.attractor.webview", "bridge"); +} + @interface NSURLResponse (BridgeAdditions) @property (nonatomic, readonly) BOOL isJavascriptResponse; @end @@ -91,13 +120,11 @@ @end -@interface SBRProcessBundleBridge () +@interface SBRProcessBundleBridge () @end @implementation SBRProcessBundleBridge { - os_log_t _log; - WKWebView *_webView; WKWebViewConfiguration *_webViewConfiguration; WKProcessPool *_processPool; @@ -107,17 +134,21 @@ NSArray *_userScripts; - dispatch_queue_t _dataTasksAccessQueue; - NSMutableDictionary *_dataTasks; - // These come from settings. WKUserStyleSheet _customizedUserStylesheet; WKUserScript *_customizedUserScript; + + // Content blocking + WKContentRuleList *_activeScriptRuleList; + void *_urlKVOContext; } - (void)tearDown { - // This was used to unregister the delegate with the web process. + if (_webView) { + @try { [_webView removeObserver:self forKeyPath:@"URL" context:_urlKVOContext]; } + @catch (__unused NSException *ex) {} + } } - (instancetype)initWithWebViewConfiguration:(WKWebViewConfiguration *)webViewConfiguration @@ -125,8 +156,6 @@ self = [super init]; if (self) { if (!webViewConfiguration) { - _log = os_log_create("net.buzzert.attractor.webview", "bridge"); - webViewConfiguration = [[WKWebViewConfiguration alloc] init]; // Set up process pool @@ -135,12 +164,7 @@ webViewConfiguration._waitsForPaintAfterViewDidMoveToWindow = NO; webViewConfiguration._applePayEnabled = YES; - - _dataTasks = [NSMutableDictionary dictionary]; - _dataTasksAccessQueue = dispatch_queue_create("net.buzzert.attractor.dataTasksAccess", DISPATCH_QUEUE_SERIAL); - - [webViewConfiguration setURLSchemeHandler:self forURLScheme:@"http"]; - [webViewConfiguration setURLSchemeHandler:self forURLScheme:@"https"]; + // No http/https interception — rely on content blocking rules instead. } _webViewConfiguration = webViewConfiguration; @@ -161,6 +185,11 @@ } _webView = webView; + _urlKVOContext = &_urlKVOContext; // unique context pointer + [_webView addObserver:self forKeyPath:@"URL" options:(NSKeyValueObservingOptionNew) context:_urlKVOContext]; + + // Initialize content blocking rules for current host + [self rebuildContentBlockingRulesForCurrentHost]; } return self; @@ -223,97 +252,18 @@ }); } -#pragma mark - -- (void)webView:(WKWebView *)webView startURLSchemeTask:(id)urlSchemeTask -{ - NSString *hostOrigin = [[_webView URL] host]; - NSURLRequest *request = [urlSchemeTask request]; - - LOG_DEBUG("Start URL scheme task: request: %@", request); - - __weak __auto_type welf = self; - NSURLSessionDataTask *dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) - { - if (!welf) return; - __strong __auto_type sself = welf; - - if (error != nil) { - [urlSchemeTask didFailWithError:error]; - } else if ([response isKindOfClass:[NSHTTPURLResponse class]]) { - NSURL *requestURL = [request URL]; - NSString *resourceOrigin = [requestURL host]; - const __auto_type allowResource = ^{ - os_log_debug(sself->_log, "Allowing resource: %@", requestURL.lastPathComponent); - [urlSchemeTask didReceiveResponse:response]; - [urlSchemeTask didReceiveData:data]; - [urlSchemeTask didFinish]; - - [self webProcessDidAllowScriptWithOrigin:resourceOrigin]; - }; - - const __auto_type denyResource = ^{ - os_log_debug(sself->_log, "Blocking resource: %@", requestURL.lastPathComponent); - NSHTTPURLResponse *altResponse = [[NSHTTPURLResponse alloc] initWithURL:requestURL - MIMEType:@"application/javascript" - expectedContentLength:0 textEncodingName:@"utf8"]; - [urlSchemeTask didReceiveResponse:altResponse]; - [urlSchemeTask didReceiveData:[NSData data]]; - [urlSchemeTask didFinish]; - - [self webProcessDidBlockScriptWithOrigin:resourceOrigin]; - }; - - // Check MIME type for JavaScript responses. - if ([response isJavascriptResponse] && ![sself allowAllScripts]) { - dispatch_async(sself->_dataTasksAccessQueue, ^{ - NSDictionary *policyTypes = [sself->_policyDataSource scriptPolicyTypeByOrigin]; - NSNumber *policyType = [policyTypes objectForKey:hostOrigin]; - - SBRScriptPolicy *policy = [[SBRScriptPolicy alloc] initWithSecurityOrigin:hostOrigin policyType:[policyType integerValue]]; - if ([policy allowsExternalJavaScriptResourceOrigin:resourceOrigin]) { - allowResource(); - } else { - denyResource(); - } - }); - } else { - allowResource(); - } - } else { - [urlSchemeTask didFailWithError:[NSError errorWithDomain:NSURLErrorDomain code:0 userInfo:nil]]; - } - - [sself->_dataTasks removeObjectForKey:request]; - }]; - - [_dataTasks setObject:dataTask forKey:request]; - [dataTask resume]; -} - -- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id)urlSchemeTask -{ - NSURLRequest *request = [urlSchemeTask request]; - NSURLSessionDataTask *dataTask = [_dataTasks objectForKey:request]; - if (dataTask) { - if ([dataTask state] != NSURLSessionTaskStateCanceling) { - [dataTask cancel]; - } - - [_dataTasks removeObjectForKey:request]; - } -} - #pragma mark Actions - (void)policyDataSourceDidChange { - // This was used when we had to signal the process bundle. + // Rebuild content blocking rules when policy changes. + [self rebuildContentBlockingRulesForCurrentHost]; } - (void)setAllowAllScripts:(BOOL)allowAllScripts { _allowAllScripts = allowAllScripts; + [self rebuildContentBlockingRulesForCurrentHost]; } - (void)setDarkModeEnabled:(BOOL)darkModeEnabled @@ -360,4 +310,119 @@ }]; } +// MARK: - Content Blocking Rules + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if (context == _urlKVOContext && [keyPath isEqualToString:@"URL"]) { + [self rebuildContentBlockingRulesForCurrentHost]; + return; + } + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; +} + +- (void)rebuildContentBlockingRulesForCurrentHost +{ + NSString *hostOrigin = _webView.URL.host; + if (!hostOrigin) { + // No page loaded yet; clear any existing rules + [self applyContentRuleListJSON:nil withName:nil]; + return; + } + + // Determine policy for this host + NSNumber *policyType = nil; + @synchronized (_policyDataSource) { + NSDictionary *policyTypes = [_policyDataSource scriptPolicyTypeByOrigin]; + policyType = [policyTypes objectForKey:hostOrigin] ?: @(0); + } + + // Alpha=0 Bravo=1 Charlie=2 Delta=3 Echo=4 + SBRScriptOriginPolicyType type = [policyType integerValue]; + if (_allowAllScripts || type >= SBRScriptOriginPolicyTypeEcho) { + // Echo or shields down: no blocking + [self applyContentRuleListJSON:nil withName:nil]; + return; + } + + // Base trigger applies only to this page's domain + NSMutableArray *rules = [NSMutableArray array]; + NSDictionary *baseScriptTrigger = @{ + @"resource-type" : @[ @"script" ], + @"if-domain" : @[ hostOrigin ] + }; + + if (type <= SBRScriptOriginPolicyTypeBravo) { + // Alpha or Bravo: block all external script subresources (inline JS is controlled via preferences elsewhere) + [rules addObject:@{ @"trigger": baseScriptTrigger, @"action": @{ @"type": @"block" } }]; + } else { + // Charlie/Delta: block third-party scripts + NSMutableDictionary *trigger = [baseScriptTrigger mutableCopy]; + trigger[@"load-type"] = @[ @"third-party" ]; + [rules addObject:@{ @"trigger": trigger, @"action": @{ @"type": @"block" } }]; + + if (type >= SBRScriptOriginPolicyTypeDelta) { + // Delta: add allowlist for common CDNs + for (NSString *cdn in CommonCDNList()) { + NSString *escaped = [NSRegularExpression escapedPatternForString:cdn]; + NSString *pattern = [NSString stringWithFormat:@".*://([^.]*\\.)?%@/", escaped]; + [rules addObject:@{ + @"trigger": @{ + @"url-filter": pattern, + @"if-domain": @[ hostOrigin ], + @"resource-type": @[ @"script" ] + }, + @"action": @{ @"type": @"ignore-previous-rules" } + }]; + } + + // Heuristic: allow cdn./* where family name is the second-level label + NSArray *components = [hostOrigin componentsSeparatedByString:@"."]; + if (components.count > 1) { + NSString *family = components[components.count - 2]; + NSString *escapedFamily = [NSRegularExpression escapedPatternForString:family]; + NSString *familyPattern = [NSString stringWithFormat:@".*://cdn\\.[^/]*%@[^/]*/", escapedFamily]; + [rules addObject:@{ + @"trigger": @{ + @"url-filter": familyPattern, + @"if-domain": @[ hostOrigin ], + @"resource-type": @[ @"script" ] + }, + @"action": @{ @"type": @"ignore-previous-rules" } + }]; + } + } + } + + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:rules options:0 error:nil]; + NSString *json = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + NSString *name = [NSString stringWithFormat:@"net.buzzert.attractor.rules.%@", hostOrigin]; + [self applyContentRuleListJSON:json withName:name]; +} + +- (void)applyContentRuleListJSON:(NSString *)json withName:(NSString *)name +{ + WKUserContentController *controller = [_webViewConfiguration userContentController]; + if (_activeScriptRuleList) { + [controller removeContentRuleList:_activeScriptRuleList]; + _activeScriptRuleList = nil; + } + + if (!json || !name) { return; } + + WKContentRuleListStore *store = [WKContentRuleListStore defaultStore]; + [store compileContentRuleListForIdentifier:name encodedContentRuleList:json completionHandler:^(WKContentRuleList * _Nullable ruleList, NSError * _Nullable error) { + if (error) { + LOG_ERROR("Failed to compile content rule list: %@", error.localizedDescription); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + self->_activeScriptRuleList = ruleList; + [controller addContentRuleList:ruleList]; + LOG_DEBUG("Applied content rule list: %@", name); + }); + }]; +} + @end