// // SBRProcessBundleBridge.m // SBrowser // // Created by James Magahern on 7/22/20. // #import "SBRProcessBundleBridge.h" #import "SBRScriptPolicy.h" #import "Hacks.h" #import #import #import #import #import #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; @property (nonatomic, readonly, getter=isForMainFrameOnly) BOOL forMainFrameOnly; - (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; - (void)__addUserScriptImmediately:(WKUserScript *)userScript; @end @implementation WKUserContentController (Private) - (void)__addUserStyleSheet:(id)userStyleSheet { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self performSelector:DecodedSelector("X2FkZFVzZXJTdHlsZVNoZWV0Og==") withObject:userStyleSheet]; #pragma clang diagnostic pop } - (void)__removeUserStyleSheet:(id)userStyleSheet { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self performSelector:DecodedSelector("X3JlbW92ZVVzZXJTdHlsZVNoZWV0Og==") withObject:userStyleSheet]; #pragma clang diagnostic pop } - (void)__addUserScriptImmediately:(WKUserScript *)userScript { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self performSelector:DecodedSelector("X2FkZFVzZXJTY3JpcHRJbW1lZGlhdGVseTo=") withObject:userScript]; #pragma clang diagnostic pop } @end #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 @implementation NSURLResponse (BridgeAdditions) - (BOOL)isJavascriptResponse { NSString *extension = [[self URL] pathExtension]; if ([[extension lowercaseString] isEqualToString:@"js"]) { return YES; } NSString *MIMEType = [self MIMEType]; if ([[MIMEType lowercaseString] containsString:@"javascript"]) { return YES; } return NO; } @end @interface SBRProcessBundleBridge () @end @implementation SBRProcessBundleBridge { WKWebView *_webView; WKWebViewConfiguration *_webViewConfiguration; WKProcessPool *_processPool; WKUserStyleSheet _darkModeStyleSheet; WKUserScript *_readabilityScript; NSArray *_userScripts; // These come from settings. WKUserStyleSheet _customizedUserStylesheet; WKUserScript *_customizedUserScript; // Content blocking WKContentRuleList *_activeScriptRuleList; void *_urlKVOContext; } - (void)tearDown { if (_webView) { @try { [_webView removeObserver:self forKeyPath:@"URL" context:_urlKVOContext]; } @catch (__unused NSException *ex) {} } } - (instancetype)initWithWebViewConfiguration:(WKWebViewConfiguration *)webViewConfiguration { self = [super init]; if (self) { if (!webViewConfiguration) { webViewConfiguration = [[WKWebViewConfiguration alloc] init]; // Set up process pool _processPool = [[WKProcessPool alloc] init]; webViewConfiguration.processPool = _processPool; webViewConfiguration._waitsForPaintAfterViewDidMoveToWindow = NO; webViewConfiguration._applePayEnabled = YES; // No http/https interception — rely on content blocking rules instead. } _webViewConfiguration = webViewConfiguration; // User scripts WKUserContentController *userContentController = [_webViewConfiguration userContentController]; for (WKUserScript *script in [self _userScripts]) { [userContentController addUserScript:script]; } // Reload customized user scripts/stylesheets from settings [self reloadCustomizedUserScriptsAndStylesheets]; // Instantiate web view WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:webViewConfiguration]; if (@available(iOS 16.0, *)) { webView.findInteractionEnabled = YES; } _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; } - (WKUserScript *)_loadScriptForResource:(NSString *)resourceName withExtension:(NSString *)extension { NSURL *url = [[NSBundle mainBundle] URLForResource:resourceName withExtension:extension]; NSString *source = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:nil]; return [[WKUserScript alloc] initWithSource:source injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES]; } - (NSArray *)_userScripts { if (!_userScripts) { _userScripts = @[ [self _loadScriptForResource:@"Tagger" withExtension:@"js"], ]; } return _userScripts; } - (void)reloadCustomizedUserScriptsAndStylesheets { WKUserContentController *userContentController = [_webViewConfiguration userContentController]; [userContentController removeAllUserScripts]; NSString *scriptSource = [[NSUserDefaults standardUserDefaults] stringForKey:@"userScript"]; if ([scriptSource length]) { _customizedUserScript = [[WKUserScript alloc] initWithSource:scriptSource injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES]; [userContentController addUserScript:_customizedUserScript]; } if (_customizedUserStylesheet) { [userContentController __removeUserStyleSheet:_customizedUserStylesheet]; } NSString *stylesheetSource = [[NSUserDefaults standardUserDefaults] stringForKey:@"userStylesheet"]; if ([stylesheetSource length]) { _customizedUserStylesheet = [[DecodedClass(WKUserStyleSheetEncodedClassName) alloc] initWithSource:stylesheetSource forMainFrameOnly:YES]; [userContentController __addUserStyleSheet:_customizedUserStylesheet]; } } #pragma mark Former methods - (void)webProcessDidAllowScriptWithOrigin:(NSString *)origin { dispatch_async(dispatch_get_main_queue(), ^{ [[self delegate] webProcess:self didAllowScriptResourceFromOrigin:origin]; }); } - (void)webProcessDidBlockScriptWithOrigin:(NSString *)origin { dispatch_async(dispatch_get_main_queue(), ^{ [[self delegate] webProcess:self didBlockScriptResourceFromOrigin:origin]; }); } #pragma mark Actions - (void)policyDataSourceDidChange { // Rebuild content blocking rules when policy changes. [self rebuildContentBlockingRulesForCurrentHost]; } - (void)setAllowAllScripts:(BOOL)allowAllScripts { _allowAllScripts = allowAllScripts; [self rebuildContentBlockingRulesForCurrentHost]; } - (void)setDarkModeEnabled:(BOOL)darkModeEnabled { _darkModeEnabled = darkModeEnabled; WKUserContentController *userContentController = [_webViewConfiguration userContentController]; if (darkModeEnabled) { if (!_darkModeStyleSheet) { NSURL *styleSheetURL = [[NSBundle mainBundle] URLForResource:@"darkmode" withExtension:@"css"]; NSString *styleSheetSource = [NSString stringWithContentsOfURL:styleSheetURL encoding:NSUTF8StringEncoding error:nil]; _darkModeStyleSheet = [[DecodedClass(WKUserStyleSheetEncodedClassName) alloc] initWithSource:styleSheetSource forMainFrameOnly:NO]; } [userContentController __addUserStyleSheet:_darkModeStyleSheet]; } else if (_darkModeStyleSheet) { [userContentController __removeUserStyleSheet:_darkModeStyleSheet]; } } - (void)parseDocumentForReaderMode:(void (^)(NSString * _Nonnull))completionBlock { WKUserContentController *userContentController = [_webViewConfiguration userContentController]; if (!_readabilityScript) { _readabilityScript = [self _loadScriptForResource:@"Readability" withExtension:@"js"]; } [userContentController __addUserScriptImmediately:_readabilityScript]; NSString *script = @"" "var documentClone = document.cloneNode(true);" "var article = new Readability(documentClone).parse();" "article.content"; os_log_t log = _log; [_webView evaluateJavaScript:script completionHandler:^(NSString *result, NSError * _Nullable error) { if (error != nil) { os_log_error(log, "Bridge: Readability error: %@", error.localizedDescription); } else { completionBlock(result); } }]; } // 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