mirror of
https://github.com/bitwarden/server.git
synced 2025-05-02 18:22:19 -05:00
[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 <cokeke@bitwarden.com> * Rename the variable name Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * 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 <cokeke@bitwarden.com> Co-authored-by: Cy Okeke <cokeke@bitwarden.com>
This commit is contained in:
parent
7ebf312b84
commit
cf7a59c077
@ -124,8 +124,20 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- servicebus
|
- servicebus
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
container_name: bw-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
profiles:
|
||||||
|
- redis
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mssql_dev_data:
|
mssql_dev_data:
|
||||||
postgres_dev_data:
|
postgres_dev_data:
|
||||||
mysql_dev_data:
|
mysql_dev_data:
|
||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
|
redis_data:
|
||||||
|
34
src/Api/Controllers/PhishingDomainsController.cs
Normal file
34
src/Api/Controllers/PhishingDomainsController.cs
Normal file
@ -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<ActionResult<ICollection<string>>> GetPhishingDomainsAsync()
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync();
|
||||||
|
return Ok(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("checksum")]
|
||||||
|
public async Task<ActionResult<string>> GetChecksumAsync()
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var checksum = await phishingDomainRepository.GetCurrentChecksumAsync();
|
||||||
|
return Ok(checksum);
|
||||||
|
}
|
||||||
|
}
|
@ -58,6 +58,13 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
.StartNow()
|
.StartNow()
|
||||||
.WithCronSchedule("0 0 * * * ?")
|
.WithCronSchedule("0 0 * * * ?")
|
||||||
.Build();
|
.Build();
|
||||||
|
var updatePhishingDomainsTrigger = TriggerBuilder.Create()
|
||||||
|
.WithIdentity("UpdatePhishingDomainsTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithSimpleSchedule(x => x
|
||||||
|
.WithIntervalInHours(24)
|
||||||
|
.RepeatForever())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
|
||||||
var jobs = new List<Tuple<Type, ITrigger>>
|
var jobs = new List<Tuple<Type, ITrigger>>
|
||||||
@ -68,6 +75,7 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
|
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
|
||||||
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
|
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
|
||||||
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
|
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
|
||||||
|
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
|
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
|
||||||
@ -96,6 +104,7 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
services.AddTransient<ValidateUsersJob>();
|
services.AddTransient<ValidateUsersJob>();
|
||||||
services.AddTransient<ValidateOrganizationsJob>();
|
services.AddTransient<ValidateOrganizationsJob>();
|
||||||
services.AddTransient<ValidateOrganizationDomainJob>();
|
services.AddTransient<ValidateOrganizationDomainJob>();
|
||||||
|
services.AddTransient<UpdatePhishingDomainsJob>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)
|
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)
|
||||||
|
97
src/Api/Jobs/UpdatePhishingDomainsJob.cs
Normal file
97
src/Api/Jobs/UpdatePhishingDomainsJob.cs
Normal file
@ -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<UpdatePhishingDomainsJob> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -182,6 +182,7 @@ public class Startup
|
|||||||
services.AddBillingOperations();
|
services.AddBillingOperations();
|
||||||
services.AddReportingServices();
|
services.AddReportingServices();
|
||||||
services.AddImportServices();
|
services.AddImportServices();
|
||||||
|
services.AddPhishingDomainServices(globalSettings);
|
||||||
|
|
||||||
// Authorization Handlers
|
// Authorization Handlers
|
||||||
services.AddAuthorizationHandlers();
|
services.AddAuthorizationHandlers();
|
||||||
|
@ -3,6 +3,10 @@ using Bit.Api.Tools.Authorization;
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.IdentityServer;
|
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.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Core.Vault.Authorization.SecurityTasks;
|
using Bit.Core.Vault.Authorization.SecurityTasks;
|
||||||
@ -109,4 +113,25 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<AzurePhishingDomainStorageService>();
|
||||||
|
services.AddSingleton<IPhishingDomainRepository, AzurePhishingDomainRepository>();
|
||||||
|
|
||||||
|
if (globalSettings.SelfHosted)
|
||||||
|
{
|
||||||
|
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainRelayQuery>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainDirectQuery>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,10 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"connectionString": "UseDevelopmentStorage=true"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,6 +71,9 @@
|
|||||||
"accessKeySecret": "SECRET",
|
"accessKeySecret": "SECRET",
|
||||||
"region": "SECRET"
|
"region": "SECRET"
|
||||||
},
|
},
|
||||||
|
"phishingDomain": {
|
||||||
|
"updateUrl": "SECRET"
|
||||||
|
},
|
||||||
"distributedIpRateLimiting": {
|
"distributedIpRateLimiting": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"maxRedisTimeoutsThreshold": 10,
|
"maxRedisTimeoutsThreshold": 10,
|
||||||
|
@ -203,6 +203,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
||||||
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
|
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
|
||||||
public const string EndUserNotifications = "pm-10609-end-user-notifications";
|
public const string EndUserNotifications = "pm-10609-end-user-notifications";
|
||||||
|
public const string PhishingDetection = "phishing-detection";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
public static List<string> GetAllKeys()
|
||||||
{
|
{
|
||||||
|
@ -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<AzurePhishingDomainStorageService> _logger;
|
||||||
|
private BlobContainerClient _containerClient;
|
||||||
|
|
||||||
|
public AzurePhishingDomainStorageService(
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
ILogger<AzurePhishingDomainStorageService> logger)
|
||||||
|
{
|
||||||
|
_blobServiceClient = new BlobServiceClient(globalSettings.Storage.ConnectionString);
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<string>> 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<string> 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<string> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Bit.Core.PhishingDomainFeatures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of ICloudPhishingDomainQuery for cloud environments
|
||||||
|
/// that directly calls the external phishing domain source
|
||||||
|
/// </summary>
|
||||||
|
public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery
|
||||||
|
{
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<CloudPhishingDomainDirectQuery> _logger;
|
||||||
|
|
||||||
|
public CloudPhishingDomainDirectQuery(
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<CloudPhishingDomainDirectQuery> logger)
|
||||||
|
{
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the SHA256 checksum of the remote phishing domains list
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The SHA256 checksum as a lowercase hex string</returns>
|
||||||
|
public async Task<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a checksum response in the format "hash *filename"
|
||||||
|
/// </summary>
|
||||||
|
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<string> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of ICloudPhishingDomainQuery for self-hosted environments
|
||||||
|
/// that relays the request to the Bitwarden cloud API
|
||||||
|
/// </summary>
|
||||||
|
public class CloudPhishingDomainRelayQuery : BaseIdentityClientService, ICloudPhishingDomainQuery
|
||||||
|
{
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
|
||||||
|
public CloudPhishingDomainRelayQuery(
|
||||||
|
IHttpClientFactory httpFactory,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
ILogger<CloudPhishingDomainRelayQuery> logger)
|
||||||
|
: base(
|
||||||
|
httpFactory,
|
||||||
|
globalSettings.Installation.ApiUri,
|
||||||
|
globalSettings.Installation.IdentityUri,
|
||||||
|
"api.licensing",
|
||||||
|
$"installation.{globalSettings.Installation.Id}",
|
||||||
|
globalSettings.Installation.Key,
|
||||||
|
logger)
|
||||||
|
{
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> 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<object, string[]>(HttpMethod.Get, "phishing-domains", null, true);
|
||||||
|
return result?.ToList() ?? new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the SHA256 checksum of the remote phishing domains list
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The SHA256 checksum as a lowercase hex string</returns>
|
||||||
|
public async Task<string> 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<object, string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
|
||||||
|
public interface ICloudPhishingDomainQuery
|
||||||
|
{
|
||||||
|
Task<List<string>> GetPhishingDomainsAsync();
|
||||||
|
Task<string> GetRemoteChecksumAsync();
|
||||||
|
}
|
8
src/Core/Repositories/IPhishingDomainRepository.cs
Normal file
8
src/Core/Repositories/IPhishingDomainRepository.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Bit.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IPhishingDomainRepository
|
||||||
|
{
|
||||||
|
Task<ICollection<string>> GetActivePhishingDomainsAsync();
|
||||||
|
Task UpdatePhishingDomainsAsync(IEnumerable<string> domains, string checksum);
|
||||||
|
Task<string> GetCurrentChecksumAsync();
|
||||||
|
}
|
@ -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<AzurePhishingDomainRepository> _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<AzurePhishingDomainRepository> logger)
|
||||||
|
{
|
||||||
|
_storageService = storageService;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<string>> GetActivePhishingDomainsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cachedDomains = await _cache.GetStringAsync(_domainsCacheKey);
|
||||||
|
if (!string.IsNullOrEmpty(cachedDomains))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Retrieved phishing domains from cache");
|
||||||
|
return JsonSerializer.Deserialize<ICollection<string>>(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<string> 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<string> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -85,6 +85,7 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
|
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
|
||||||
public virtual string DevelopmentDirectory { get; set; }
|
public virtual string DevelopmentDirectory { get; set; }
|
||||||
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
|
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
|
||||||
|
public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings();
|
||||||
|
|
||||||
public virtual bool EnableEmailVerification { get; set; }
|
public virtual bool EnableEmailVerification { get; set; }
|
||||||
public virtual string KdfDefaultHashKey { get; set; }
|
public virtual string KdfDefaultHashKey { get; set; }
|
||||||
@ -644,6 +645,12 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public int MaxNetworkRetries { get; set; } = 2;
|
public int MaxNetworkRetries { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PhishingDomainSettings : IPhishingDomainSettings
|
||||||
|
{
|
||||||
|
public string UpdateUrl { get; set; }
|
||||||
|
public string ChecksumUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class DistributedIpRateLimitingSettings
|
public class DistributedIpRateLimitingSettings
|
||||||
{
|
{
|
||||||
public string RedisConnectionString { get; set; }
|
public string RedisConnectionString { get; set; }
|
||||||
|
@ -29,4 +29,5 @@ public interface IGlobalSettings
|
|||||||
string DevelopmentDirectory { get; set; }
|
string DevelopmentDirectory { get; set; }
|
||||||
IWebPushSettings WebPush { get; set; }
|
IWebPushSettings WebPush { get; set; }
|
||||||
GlobalSettings.EventLoggingSettings EventLogging { get; set; }
|
GlobalSettings.EventLoggingSettings EventLogging { get; set; }
|
||||||
|
IPhishingDomainSettings PhishingDomain { get; set; }
|
||||||
}
|
}
|
||||||
|
7
src/Core/Settings/IPhishingDomainSettings.cs
Normal file
7
src/Core/Settings/IPhishingDomainSettings.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.Settings;
|
||||||
|
|
||||||
|
public interface IPhishingDomainSettings
|
||||||
|
{
|
||||||
|
string UpdateUrl { get; set; }
|
||||||
|
string ChecksumUrl { get; set; }
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user