From 88d82fd4833993151cea07e89534407164a1ec72 Mon Sep 17 00:00:00 2001 From: Ivan Lepesii Date: Mon, 4 May 2026 18:01:46 -0400 Subject: [PATCH 1/2] Validate hosts block integrity --- Block Management/HostFileBlocker.h | 1 + Block Management/HostFileBlocker.m | 70 +++++++++++++++++++++++++++ Block Management/HostFileBlockerSet.m | 8 +++ Common/SCSettings.m | 8 +-- Daemon/SCDaemonBlockMethods.m | 3 +- SelfControlTests/SCUtilityTests.m | 32 ++++++++++++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/Block Management/HostFileBlocker.h b/Block Management/HostFileBlocker.h index eb47b3eb..cf1a49c7 100755 --- a/Block Management/HostFileBlocker.h +++ b/Block Management/HostFileBlocker.h @@ -42,6 +42,7 @@ - (void)appendExistingBlockWithRuleForDomain:(NSString*)domainName; - (BOOL)containsSelfControlBlock; +- (BOOL)containsExpectedRulesForBlocklist:(NSArray*)blocklist; - (void)removeSelfControlBlock; diff --git a/Block Management/HostFileBlocker.m b/Block Management/HostFileBlocker.m index d96f71a7..d2daa6ca 100755 --- a/Block Management/HostFileBlocker.m +++ b/Block Management/HostFileBlocker.m @@ -21,6 +21,8 @@ // along with this program. If not, see . #import "HostFileBlocker.h" +#import "SCBlockEntry.h" +#import "NSString+IPAddress.h" NSString* const kHostFileBlockerPath = @"/etc/hosts"; NSString* const kHostFileBlockerSelfControlHeader = @"# BEGIN SELFCONTROL BLOCK"; @@ -182,6 +184,74 @@ - (BOOL)containsSelfControlBlock { return ret; } +- (NSSet*)selfControlBlockRuleSet { + NSMutableSet* ruleSet = [NSMutableSet set]; + + NSRange startRange = [newFileContents rangeOfString: kHostFileBlockerSelfControlHeader]; + NSRange endRange = [newFileContents rangeOfString: kHostFileBlockerSelfControlFooter]; + if (startRange.location == NSNotFound || endRange.location == NSNotFound || endRange.location <= startRange.location) { + return ruleSet; + } + + NSUInteger blockStart = startRange.location + startRange.length; + NSRange blockRange = NSMakeRange(blockStart, endRange.location - blockStart); + NSString* blockContents = [newFileContents substringWithRange: blockRange]; + NSArray* lines = [blockContents componentsSeparatedByCharactersInSet: [NSCharacterSet newlineCharacterSet]]; + NSCharacterSet* whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + + for (NSString* line in lines) { + NSString* trimmedLine = [line stringByTrimmingCharactersInSet: whitespace]; + if (trimmedLine.length == 0 || [trimmedLine hasPrefix: @"#"]) { + continue; + } + + NSArray* parts = [trimmedLine componentsSeparatedByCharactersInSet: whitespace]; + NSMutableArray* nonEmptyParts = [NSMutableArray array]; + for (NSString* part in parts) { + if (part.length > 0) { + [nonEmptyParts addObject: part]; + } + } + if (nonEmptyParts.count < 2) { + continue; + } + + NSString* address = nonEmptyParts[0]; + for (NSUInteger i = 1; i < nonEmptyParts.count; i++) { + [ruleSet addObject: [NSString stringWithFormat: @"%@ %@", address, nonEmptyParts[i]]]; + } + } + + return ruleSet; +} + +- (BOOL)containsExpectedRulesForBlocklist:(NSArray*)blocklist { + [strLock lock]; + + BOOL ret = ([newFileContents rangeOfString: kHostFileBlockerSelfControlHeader].location != NSNotFound); + if (ret) { + NSSet* blockRuleSet = [self selfControlBlockRuleSet]; + + for (NSString* blocklistString in blocklist) { + SCBlockEntry* entry = [SCBlockEntry entryFromString: blocklistString]; + if (entry == nil || entry.port || [entry.hostname isEqualToString: @"*"] || [entry.hostname isValidIPAddress]) { + continue; + } + + NSString* ipv4Rule = [NSString stringWithFormat: @"0.0.0.0 %@", entry.hostname]; + NSString* ipv6Rule = [NSString stringWithFormat: @":: %@", entry.hostname]; + if (![blockRuleSet containsObject: ipv4Rule] || ![blockRuleSet containsObject: ipv6Rule]) { + NSLog(@"INFO: SelfControl hosts block is missing expected rule(s) for %@", entry.hostname); + ret = NO; + break; + } + } + } + + [strLock unlock]; + return ret; +} + - (void)removeSelfControlBlock { if(![self containsSelfControlBlock]) return; diff --git a/Block Management/HostFileBlockerSet.m b/Block Management/HostFileBlockerSet.m index 0cc61b2e..b20a8696 100644 --- a/Block Management/HostFileBlockerSet.m +++ b/Block Management/HostFileBlockerSet.m @@ -112,6 +112,14 @@ - (BOOL)containsSelfControlBlock { return ret; } +- (BOOL)containsExpectedRulesForBlocklist:(NSArray*)blocklist { + BOOL ret = YES; + for (HostFileBlocker* blocker in self.blockers) { + ret = ret && [blocker containsExpectedRulesForBlocklist: blocklist]; + } + return ret; +} + - (void)removeSelfControlBlock { for (HostFileBlocker* blocker in self.blockers) { [blocker removeSelfControlBlock]; diff --git a/Common/SCSettings.m b/Common/SCSettings.m index e0b35082..a5c1cacc 100644 --- a/Common/SCSettings.m +++ b/Common/SCSettings.m @@ -271,10 +271,10 @@ - (void)writeSettingsWithCompletion:(nullable void(^)(NSError* _Nullable))comple NSError* chmodErr; BOOL chmodSuccessful = [[NSFileManager defaultManager] setAttributes: @{ - NSFileOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], - NSFileGroupOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], - NSFilePosixPermissions: [NSNumber numberWithShort: 0755] - } + NSFileOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], + NSFileGroupOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], + NSFilePosixPermissions: [NSNumber numberWithShort: 0600] + } ofItemAtPath: SCSettings.securedSettingsFilePath error: &chmodErr]; diff --git a/Daemon/SCDaemonBlockMethods.m b/Daemon/SCDaemonBlockMethods.m index 1a6cf0a5..209b3f81 100644 --- a/Daemon/SCDaemonBlockMethods.m +++ b/Daemon/SCDaemonBlockMethods.m @@ -349,7 +349,8 @@ + (void)checkBlockIntegrity { SCSettings* settings = [SCSettings sharedSettings]; PacketFilter* pf = [[PacketFilter alloc] init]; HostFileBlockerSet* hostFileBlockerSet = [[HostFileBlockerSet alloc] init]; - if(![pf containsSelfControlBlock] || (![settings boolForKey: @"ActiveBlockAsWhitelist"] && ![hostFileBlockerSet.defaultBlocker containsSelfControlBlock])) { + BOOL hostsBlockIsValid = [settings boolForKey: @"ActiveBlockAsWhitelist"] || [hostFileBlockerSet.defaultBlocker containsExpectedRulesForBlocklist: [settings valueForKey: @"ActiveBlocklist"]]; + if(![pf containsSelfControlBlock] || !hostsBlockIsValid) { NSLog(@"INFO: Block is missing in PF or hosts, re-adding..."); // The firewall is missing at least the block header. Let's clear everything // before we re-add to make sure everything goes smoothly. diff --git a/SelfControlTests/SCUtilityTests.m b/SelfControlTests/SCUtilityTests.m index a92caa7b..92cac40d 100644 --- a/SelfControlTests/SCUtilityTests.m +++ b/SelfControlTests/SCUtilityTests.m @@ -10,6 +10,7 @@ #import "SCSentry.h" #import "SCErr.h" #import "SCSettings.h" +#import "HostFileBlocker.h" @interface SCUtilityTests : XCTestCase @@ -164,6 +165,37 @@ - (void) testModernBlockDetection { XCTAssert([SCBlockUtilities currentBlockIsExpired]); } +- (NSURL*)temporaryHostsFileURLWithName:(NSString*)name contents:(NSString*)contents { + NSURL* url = [[NSURL fileURLWithPath: NSTemporaryDirectory()] URLByAppendingPathComponent: name]; + [contents writeToURL: url atomically: YES encoding: NSUTF8StringEncoding error: nil]; + return url; +} + +- (void) testHostBlockIntegrityRequiresExpectedRules { + NSURL* hostsURL = [self temporaryHostsFileURLWithName: @"selfcontrol-empty-hosts-block-test" + contents: @"127.0.0.1 localhost\n\n# BEGIN SELFCONTROL BLOCK\n# END SELFCONTROL BLOCK\n"]; + HostFileBlocker* blocker = [[HostFileBlocker alloc] initWithPath: hostsURL.path]; + + XCTAssert([blocker containsSelfControlBlock]); + XCTAssertFalse([blocker containsExpectedRulesForBlocklist: @[ @"youtube.com" ]]); + + [[NSFileManager defaultManager] removeItemAtURL: hostsURL error: nil]; +} + +- (void) testHostBlockIntegrityAcceptsExpectedRules { + NSURL* hostsURL = [self temporaryHostsFileURLWithName: @"selfcontrol-valid-hosts-block-test" + contents: @"127.0.0.1 localhost\n"]; + HostFileBlocker* blocker = [[HostFileBlocker alloc] initWithPath: hostsURL.path]; + [blocker addSelfControlBlockHeader]; + [blocker addRuleBlockingDomain: @"youtube.com"]; + [blocker addSelfControlBlockFooter]; + + XCTAssert([blocker containsExpectedRulesForBlocklist: @[ @"youtube.com" ]]); + XCTAssertFalse([blocker containsExpectedRulesForBlocklist: @[ @"youtube.com", @"www.youtube.com" ]]); + + [[NSFileManager defaultManager] removeItemAtURL: hostsURL error: nil]; +} + - (void) testLegacyBlockDetection { // test blockIsRunningInLegacyDictionary // the block is "running" even if it's expired, since it hasn't been removed From 43665fa35fd459ef1863205a97046142a4e49722 Mon Sep 17 00:00:00 2001 From: Ivan Lepesii Date: Mon, 4 May 2026 21:09:35 -0400 Subject: [PATCH 2/2] Address hosts integrity review feedback --- Block Management/HostFileBlocker.m | 2 +- Common/SCSettings.m | 2 +- SelfControlTests/SCUtilityTests.m | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Block Management/HostFileBlocker.m b/Block Management/HostFileBlocker.m index d2daa6ca..c1127be7 100755 --- a/Block Management/HostFileBlocker.m +++ b/Block Management/HostFileBlocker.m @@ -234,7 +234,7 @@ - (BOOL)containsExpectedRulesForBlocklist:(NSArray*)blocklist { for (NSString* blocklistString in blocklist) { SCBlockEntry* entry = [SCBlockEntry entryFromString: blocklistString]; - if (entry == nil || entry.port || [entry.hostname isEqualToString: @"*"] || [entry.hostname isValidIPAddress]) { + if (entry == nil || entry.port || [entry.hostname rangeOfString: @"*"].location != NSNotFound || [entry.hostname isValidIPAddress]) { continue; } diff --git a/Common/SCSettings.m b/Common/SCSettings.m index a5c1cacc..2d301116 100644 --- a/Common/SCSettings.m +++ b/Common/SCSettings.m @@ -273,7 +273,7 @@ - (void)writeSettingsWithCompletion:(nullable void(^)(NSError* _Nullable))comple setAttributes: @{ NSFileOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], NSFileGroupOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], - NSFilePosixPermissions: [NSNumber numberWithShort: 0600] + NSFilePosixPermissions: [NSNumber numberWithShort: 0644] } ofItemAtPath: SCSettings.securedSettingsFilePath error: &chmodErr]; diff --git a/SelfControlTests/SCUtilityTests.m b/SelfControlTests/SCUtilityTests.m index 92cac40d..2b785098 100644 --- a/SelfControlTests/SCUtilityTests.m +++ b/SelfControlTests/SCUtilityTests.m @@ -196,6 +196,16 @@ - (void) testHostBlockIntegrityAcceptsExpectedRules { [[NSFileManager defaultManager] removeItemAtURL: hostsURL error: nil]; } +- (void) testHostBlockIntegrityIgnoresRulesHostsCannotRepresent { + NSURL* hostsURL = [self temporaryHostsFileURLWithName: @"selfcontrol-hosts-unrepresentable-rules-test" + contents: @"127.0.0.1 localhost\n\n# BEGIN SELFCONTROL BLOCK\n# END SELFCONTROL BLOCK\n"]; + HostFileBlocker* blocker = [[HostFileBlocker alloc] initWithPath: hostsURL.path]; + + XCTAssert([blocker containsExpectedRulesForBlocklist: @[ @"*.youtube.com", @"127.0.0.1", @"example.com:443" ]]); + + [[NSFileManager defaultManager] removeItemAtURL: hostsURL error: nil]; +} + - (void) testLegacyBlockDetection { // test blockIsRunningInLegacyDictionary // the block is "running" even if it's expired, since it hasn't been removed