From cf7a59c0772985086eea847cfd092f771a1aba45 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:03:59 -0400 Subject: [PATCH] [Innovation Sprint] Phishing Detection (#5516) * Initial stubbing out of the phishing service * Add the phishing domain controller * Add changes for the phishing domain get * Add distributed cache to the phishing domain Signed-off-by: Cy Okeke * Rename the variable name Signed-off-by: Cy Okeke * Removed IPhishingDomainService * Feature/phishing detection cronjob (#5512) * Added caching to EF implementation. Added error handling and logging * Refactored update method to use sqlbulkcopy instead of performing a round trip for each new insert * Initial implementation for quartz job to get list of phishing domains * Updated phishing domain settings to be its own interface * Add phishing domain detection with checksum-based updates * Updated auth for phishing domain endpoints to either require api, or licensing claims to support both web and browser clients, and selfhost api clients * [Innovation Sprint] Updated Phishing domains to rely on blob storage (#5517) * Updated phishing detection data layer to rely on azure blob storage instead of sql server * dotnet format * Took rider refactors * Ensuring phishing.testcategory.com exists to test against * Added redis to dev's docker-compose * Removed redis from cloud profile * Remove the Authorize attribute * error whitespace fix whitespace formatting * error WHITESPACE: Fix whitespace formatting * Wrapped phishing detection feature behind feature flag (#5532) * Increased timeout for fetching source list a bunch * Removed PhishingDomains policy --------- Signed-off-by: Cy Okeke Co-authored-by: Cy Okeke --- dev/docker-compose.yml | 12 ++ .../Controllers/PhishingDomainsController.cs | 34 +++++ src/Api/Jobs/JobsHostedService.cs | 9 ++ src/Api/Jobs/UpdatePhishingDomainsJob.cs | 97 ++++++++++++++ src/Api/Startup.cs | 1 + .../Utilities/ServiceCollectionExtensions.cs | 25 ++++ src/Api/appsettings.Development.json | 4 + src/Api/appsettings.json | 3 + src/Core/Constants.cs | 1 + .../AzurePhishingDomainStorageService.cs | 92 +++++++++++++ .../CloudPhishingDomainDirectQuery.cs | 100 ++++++++++++++ .../CloudPhishingDomainRelayQuery.cs | 66 +++++++++ .../Interfaces/ICloudPhishingDomainQuery.cs | 7 + .../Repositories/IPhishingDomainRepository.cs | 8 ++ .../AzurePhishingDomainRepository.cs | 126 ++++++++++++++++++ src/Core/Settings/GlobalSettings.cs | 7 + src/Core/Settings/IGlobalSettings.cs | 1 + src/Core/Settings/IPhishingDomainSettings.cs | 7 + 18 files changed, 600 insertions(+) create mode 100644 src/Api/Controllers/PhishingDomainsController.cs create mode 100644 src/Api/Jobs/UpdatePhishingDomainsJob.cs create mode 100644 src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs create mode 100644 src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs create mode 100644 src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs create mode 100644 src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs create mode 100644 src/Core/Repositories/IPhishingDomainRepository.cs create mode 100644 src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs create mode 100644 src/Core/Settings/IPhishingDomainSettings.cs diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 1bfbe0a9d7..a21f1ac6b8 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -124,8 +124,20 @@ services: profiles: - servicebus + redis: + image: redis:alpine + container_name: bw-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + profiles: + - redis + volumes: mssql_dev_data: postgres_dev_data: mysql_dev_data: rabbitmq_data: + redis_data: diff --git a/src/Api/Controllers/PhishingDomainsController.cs b/src/Api/Controllers/PhishingDomainsController.cs new file mode 100644 index 0000000000..f0c1a65648 --- /dev/null +++ b/src/Api/Controllers/PhishingDomainsController.cs @@ -0,0 +1,34 @@ +using Bit.Core; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Controllers; + +[Route("phishing-domains")] +public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller +{ + [HttpGet] + public async Task>> GetPhishingDomainsAsync() + { + if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) + { + return NotFound(); + } + + var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync(); + return Ok(domains); + } + + [HttpGet("checksum")] + public async Task> GetChecksumAsync() + { + if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) + { + return NotFound(); + } + + var checksum = await phishingDomainRepository.GetCurrentChecksumAsync(); + return Ok(checksum); + } +} diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index acd95a0213..57b827a8be 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -58,6 +58,13 @@ public class JobsHostedService : BaseJobsHostedService .StartNow() .WithCronSchedule("0 0 * * * ?") .Build(); + var updatePhishingDomainsTrigger = TriggerBuilder.Create() + .WithIdentity("UpdatePhishingDomainsTrigger") + .StartNow() + .WithSimpleSchedule(x => x + .WithIntervalInHours(24) + .RepeatForever()) + .Build(); var jobs = new List> @@ -68,6 +75,7 @@ public class JobsHostedService : BaseJobsHostedService new Tuple(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger), new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), + new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), }; if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication) @@ -96,6 +104,7 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } public static void AddCommercialSecretsManagerJobServices(IServiceCollection services) diff --git a/src/Api/Jobs/UpdatePhishingDomainsJob.cs b/src/Api/Jobs/UpdatePhishingDomainsJob.cs new file mode 100644 index 0000000000..355f2af69b --- /dev/null +++ b/src/Api/Jobs/UpdatePhishingDomainsJob.cs @@ -0,0 +1,97 @@ +using Bit.Core; +using Bit.Core.Jobs; +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Quartz; + +namespace Bit.Api.Jobs; + +public class UpdatePhishingDomainsJob : BaseJob +{ + private readonly GlobalSettings _globalSettings; + private readonly IPhishingDomainRepository _phishingDomainRepository; + private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery; + private readonly IFeatureService _featureService; + public UpdatePhishingDomainsJob( + GlobalSettings globalSettings, + IPhishingDomainRepository phishingDomainRepository, + ICloudPhishingDomainQuery cloudPhishingDomainQuery, + IFeatureService featureService, + ILogger logger) + : base(logger) + { + _globalSettings = globalSettings; + _phishingDomainRepository = phishingDomainRepository; + _cloudPhishingDomainQuery = cloudPhishingDomainQuery; + _featureService = featureService; + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled."); + return; + } + + if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured."); + return; + } + + if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings."); + return; + } + + var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync(); + if (string.IsNullOrWhiteSpace(remoteChecksum)) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update."); + return; + } + + var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync(); + + if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Phishing domains list is up to date (checksum: {Checksum}). Skipping update.", + currentChecksum); + return; + } + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.", + currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source"); + + try + { + var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync(); + if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase)) + { + domains.Add("phishing.testcategory.com"); + } + + if (domains.Count > 0) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.", + domains.Count, remoteChecksum); + await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains."); + } + else + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update."); + } + } + catch (Exception ex) + { + _logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains."); + } + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2872a5b88b..40448f722d 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -182,6 +182,7 @@ public class Startup services.AddBillingOperations(); services.AddReportingServices(); services.AddImportServices(); + services.AddPhishingDomainServices(globalSettings); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 4c8589657e..e6a20fe364 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -3,6 +3,10 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.IdentityServer; +using Bit.Core.PhishingDomainFeatures; +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Repositories.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.SecurityTasks; @@ -109,4 +113,25 @@ public static class ServiceCollectionExtensions services.AddScoped(); } + + public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) + { + services.AddHttpClient("PhishingDomains", client => + { + client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden"); + client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow + }); + + services.AddSingleton(); + services.AddSingleton(); + + if (globalSettings.SelfHosted) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + } + } } diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 2f33d87ae8..82fb951261 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -37,6 +37,10 @@ }, "storage": { "connectionString": "UseDevelopmentStorage=true" + }, + "phishingDomain": { + "updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", + "checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256" } } } diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index 98b210cb1e..f8a69dcfac 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -71,6 +71,9 @@ "accessKeySecret": "SECRET", "region": "SECRET" }, + "phishingDomain": { + "updateUrl": "SECRET" + }, "distributedIpRateLimiting": { "enabled": true, "maxRedisTimeoutsThreshold": 10, diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5f0265df41..a05b89a94f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -203,6 +203,7 @@ public static class FeatureFlagKeys public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public const string EndUserNotifications = "pm-10609-end-user-notifications"; + public const string PhishingDetection = "phishing-detection"; public static List GetAllKeys() { diff --git a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs new file mode 100644 index 0000000000..9af9c94e1d --- /dev/null +++ b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs @@ -0,0 +1,92 @@ +using System.Text; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.PhishingDomainFeatures; + +public class AzurePhishingDomainStorageService +{ + private const string _containerName = "phishingdomains"; + private const string _domainsFileName = "domains.txt"; + private const string _checksumFileName = "checksum.txt"; + + private readonly BlobServiceClient _blobServiceClient; + private readonly ILogger _logger; + private BlobContainerClient _containerClient; + + public AzurePhishingDomainStorageService( + GlobalSettings globalSettings, + ILogger logger) + { + _blobServiceClient = new BlobServiceClient(globalSettings.Storage.ConnectionString); + _logger = logger; + } + + public async Task> GetDomainsAsync() + { + await InitAsync(); + + var blobClient = _containerClient.GetBlobClient(_domainsFileName); + if (!await blobClient.ExistsAsync()) + { + return []; + } + + var response = await blobClient.DownloadAsync(); + using var streamReader = new StreamReader(response.Value.Content); + var content = await streamReader.ReadToEndAsync(); + + return [.. content + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; + } + + public async Task GetChecksumAsync() + { + await InitAsync(); + + var blobClient = _containerClient.GetBlobClient(_checksumFileName); + if (!await blobClient.ExistsAsync()) + { + return string.Empty; + } + + var response = await blobClient.DownloadAsync(); + using var streamReader = new StreamReader(response.Value.Content); + return (await streamReader.ReadToEndAsync()).Trim(); + } + + public async Task UpdateDomainsAsync(IEnumerable domains, string checksum) + { + await InitAsync(); + + var domainsContent = string.Join(Environment.NewLine, domains); + var domainsStream = new MemoryStream(Encoding.UTF8.GetBytes(domainsContent)); + var domainsBlobClient = _containerClient.GetBlobClient(_domainsFileName); + + await domainsBlobClient.UploadAsync(domainsStream, new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } + }, CancellationToken.None); + + var checksumStream = new MemoryStream(Encoding.UTF8.GetBytes(checksum)); + var checksumBlobClient = _containerClient.GetBlobClient(_checksumFileName); + + await checksumBlobClient.UploadAsync(checksumStream, new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } + }, CancellationToken.None); + } + + private async Task InitAsync() + { + if (_containerClient is null) + { + _containerClient = _blobServiceClient.GetBlobContainerClient(_containerName); + await _containerClient.CreateIfNotExistsAsync(); + } + } +} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs new file mode 100644 index 0000000000..b059eac0e8 --- /dev/null +++ b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs @@ -0,0 +1,100 @@ +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.PhishingDomainFeatures; + +/// +/// Implementation of ICloudPhishingDomainQuery for cloud environments +/// that directly calls the external phishing domain source +/// +public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery +{ + private readonly IGlobalSettings _globalSettings; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CloudPhishingDomainDirectQuery( + IGlobalSettings globalSettings, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _globalSettings = globalSettings; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task> GetPhishingDomainsAsync() + { + if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) + { + throw new InvalidOperationException("Phishing domain update URL is not configured."); + } + + var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); + var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.UpdateUrl); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return ParseDomains(content); + } + + /// + /// Gets the SHA256 checksum of the remote phishing domains list + /// + /// The SHA256 checksum as a lowercase hex string + public async Task GetRemoteChecksumAsync() + { + if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.ChecksumUrl)) + { + _logger.LogWarning("Phishing domain checksum URL is not configured."); + return string.Empty; + } + + try + { + var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); + var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.ChecksumUrl); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return ParseChecksumResponse(content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving phishing domain checksum from {Url}", + _globalSettings.PhishingDomain.ChecksumUrl); + return string.Empty; + } + } + + /// + /// Parses a checksum response in the format "hash *filename" + /// + private static string ParseChecksumResponse(string checksumContent) + { + if (string.IsNullOrWhiteSpace(checksumContent)) + { + return string.Empty; + } + + // Format is typically "hash *filename" + var parts = checksumContent.Split(' ', 2); + + return parts.Length > 0 ? parts[0].Trim() : string.Empty; + } + + private static List ParseDomains(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return []; + } + + return content + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#")) + .ToList(); + } +} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs new file mode 100644 index 0000000000..2685d36a7f --- /dev/null +++ b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs @@ -0,0 +1,66 @@ +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Services; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.PhishingDomainFeatures; + +/// +/// Implementation of ICloudPhishingDomainQuery for self-hosted environments +/// that relays the request to the Bitwarden cloud API +/// +public class CloudPhishingDomainRelayQuery : BaseIdentityClientService, ICloudPhishingDomainQuery +{ + private readonly IGlobalSettings _globalSettings; + + public CloudPhishingDomainRelayQuery( + IHttpClientFactory httpFactory, + IGlobalSettings globalSettings, + ILogger logger) + : base( + httpFactory, + globalSettings.Installation.ApiUri, + globalSettings.Installation.IdentityUri, + "api.licensing", + $"installation.{globalSettings.Installation.Id}", + globalSettings.Installation.Key, + logger) + { + _globalSettings = globalSettings; + } + + public async Task> GetPhishingDomainsAsync() + { + if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) + { + throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); + } + + var result = await SendAsync(HttpMethod.Get, "phishing-domains", null, true); + return result?.ToList() ?? new List(); + } + + /// + /// Gets the SHA256 checksum of the remote phishing domains list + /// + /// The SHA256 checksum as a lowercase hex string + public async Task GetRemoteChecksumAsync() + { + if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) + { + throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); + } + + try + { + // For self-hosted environments, we get the checksum from the Bitwarden cloud API + var result = await SendAsync(HttpMethod.Get, "phishing-domains/checksum", null, true); + return result ?? string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving phishing domain checksum from Bitwarden cloud API"); + return string.Empty; + } + } +} diff --git a/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs b/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs new file mode 100644 index 0000000000..dac91747f7 --- /dev/null +++ b/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.PhishingDomainFeatures.Interfaces; + +public interface ICloudPhishingDomainQuery +{ + Task> GetPhishingDomainsAsync(); + Task GetRemoteChecksumAsync(); +} diff --git a/src/Core/Repositories/IPhishingDomainRepository.cs b/src/Core/Repositories/IPhishingDomainRepository.cs new file mode 100644 index 0000000000..2d653b0a43 --- /dev/null +++ b/src/Core/Repositories/IPhishingDomainRepository.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Repositories; + +public interface IPhishingDomainRepository +{ + Task> GetActivePhishingDomainsAsync(); + Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum); + Task GetCurrentChecksumAsync(); +} diff --git a/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs b/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs new file mode 100644 index 0000000000..2d4ea15b7e --- /dev/null +++ b/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using Bit.Core.PhishingDomainFeatures; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Repositories.Implementations; + +public class AzurePhishingDomainRepository : IPhishingDomainRepository +{ + private readonly AzurePhishingDomainStorageService _storageService; + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + private const string _domainsCacheKey = "PhishingDomains_v1"; + private const string _checksumCacheKey = "PhishingDomains_Checksum_v1"; + private static readonly DistributedCacheEntryOptions _cacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24), + SlidingExpiration = TimeSpan.FromHours(1) + }; + + public AzurePhishingDomainRepository( + AzurePhishingDomainStorageService storageService, + IDistributedCache cache, + ILogger logger) + { + _storageService = storageService; + _cache = cache; + _logger = logger; + } + + public async Task> GetActivePhishingDomainsAsync() + { + try + { + var cachedDomains = await _cache.GetStringAsync(_domainsCacheKey); + if (!string.IsNullOrEmpty(cachedDomains)) + { + _logger.LogDebug("Retrieved phishing domains from cache"); + return JsonSerializer.Deserialize>(cachedDomains) ?? []; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve phishing domains from cache"); + } + + var domains = await _storageService.GetDomainsAsync(); + + try + { + await _cache.SetStringAsync( + _domainsCacheKey, + JsonSerializer.Serialize(domains), + _cacheOptions); + _logger.LogDebug("Stored {Count} phishing domains in cache", domains.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to store phishing domains in cache"); + } + + return domains; + } + + public async Task GetCurrentChecksumAsync() + { + try + { + var cachedChecksum = await _cache.GetStringAsync(_checksumCacheKey); + if (!string.IsNullOrEmpty(cachedChecksum)) + { + _logger.LogDebug("Retrieved phishing domain checksum from cache"); + return cachedChecksum; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve phishing domain checksum from cache"); + } + + var checksum = await _storageService.GetChecksumAsync(); + + try + { + if (!string.IsNullOrEmpty(checksum)) + { + await _cache.SetStringAsync( + _checksumCacheKey, + checksum, + _cacheOptions); + _logger.LogDebug("Stored phishing domain checksum in cache"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to store phishing domain checksum in cache"); + } + + return checksum; + } + + public async Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum) + { + var domainsList = domains.ToList(); + await _storageService.UpdateDomainsAsync(domainsList, checksum); + + try + { + await _cache.SetStringAsync( + _domainsCacheKey, + JsonSerializer.Serialize(domainsList), + _cacheOptions); + + await _cache.SetStringAsync( + _checksumCacheKey, + checksum, + _cacheOptions); + + _logger.LogDebug("Updated phishing domains cache after update operation"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update phishing domains in cache"); + } + } +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 2b658c65b3..519889db45 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -85,6 +85,7 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); + public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings(); public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } @@ -644,6 +645,12 @@ public class GlobalSettings : IGlobalSettings public int MaxNetworkRetries { get; set; } = 2; } + public class PhishingDomainSettings : IPhishingDomainSettings + { + public string UpdateUrl { get; set; } + public string ChecksumUrl { get; set; } + } + public class DistributedIpRateLimitingSettings { public string RedisConnectionString { get; set; } diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 411014ea32..d77842373e 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -29,4 +29,5 @@ public interface IGlobalSettings string DevelopmentDirectory { get; set; } IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } + IPhishingDomainSettings PhishingDomain { get; set; } } diff --git a/src/Core/Settings/IPhishingDomainSettings.cs b/src/Core/Settings/IPhishingDomainSettings.cs new file mode 100644 index 0000000000..2e4a901a5a --- /dev/null +++ b/src/Core/Settings/IPhishingDomainSettings.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Settings; + +public interface IPhishingDomainSettings +{ + string UpdateUrl { get; set; } + string ChecksumUrl { get; set; } +}