diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index 27ee078d95..669bcd0203 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -12,7 +12,11 @@ using Bit.Api.Utilities; using System.Collections.Generic; using Bit.Core.Models.Table; using Bit.Core.Settings; - +using Core.Models.Data; +using Microsoft.Azure.EventGrid.Models; +using Bit.Core.Models.Data; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace Bit.Api.Controllers { @@ -26,6 +30,7 @@ namespace Bit.Api.Controllers private readonly IUserService _userService; private readonly IAttachmentStorageService _attachmentStorageService; private readonly ICurrentContext _currentContext; + private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; public CiphersController( @@ -35,6 +40,7 @@ namespace Bit.Api.Controllers IUserService userService, IAttachmentStorageService attachmentStorageService, ICurrentContext currentContext, + ILogger logger, GlobalSettings globalSettings) { _cipherRepository = cipherRepository; @@ -43,6 +49,7 @@ namespace Bit.Api.Controllers _userService = userService; _attachmentStorageService = attachmentStorageService; _currentContext = currentContext; + _logger = logger; _globalSettings = globalSettings; } @@ -562,6 +569,82 @@ namespace Bit.Api.Controllers } } + [HttpPost("{id}/attachment/v2")] + public async Task PostAttachment(string id, [FromBody] AttachmentRequestModel request) + { + var idGuid = new Guid(id); + var userId = _userService.GetProperUserId(User).Value; + var cipher = request.AdminRequest ? + await _cipherRepository.GetOrganizationDetailsByIdAsync(idGuid) : + await _cipherRepository.GetByIdAsync(idGuid, userId); + + if (cipher == null || (request.AdminRequest && (!cipher.OrganizationId.HasValue || + !_currentContext.ManageAllCollections(cipher.OrganizationId.Value)))) + { + throw new NotFoundException(); + } + + if (request.FileSize > CipherService.MAX_FILE_SIZE && !_globalSettings.SelfHosted) + { + throw new BadRequestException($"Max file size is {CipherService.MAX_FILE_SIZE_READABLE}."); + } + + + var (attachmentId, uploadUrl) = await _cipherService.CreateAttachmentForDelayedUploadAsync(cipher, request, userId); + return new AttachmentUploadDataResponseModel + { + AttachmentId = attachmentId, + Url = uploadUrl, + FileUploadType = _attachmentStorageService.FileUploadType, + CipherResponse = request.AdminRequest ? null : new CipherResponseModel((CipherDetails)cipher, _globalSettings), + CipherMiniResponse = request.AdminRequest ? new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp) : null, + }; + } + + [HttpGet("{id}/attachment/{attachmentId}")] + public async Task RenewFileUploadUrl(string id, string attachmentId) + { + var userId = _userService.GetProperUserId(User).Value; + var cipherId = new Guid(id); + var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId); + var attachments = cipher?.GetAttachments(); + + if (attachments == null || !attachments.ContainsKey(attachmentId)) + { + throw new NotFoundException(); + } + + return new AttachmentUploadDataResponseModel + { + Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachments[attachmentId]), + FileUploadType = _attachmentStorageService.FileUploadType, + }; + } + + [HttpPost("{id}/attachment/{attachmentId}")] + [DisableFormValueModelBinding] + public async Task PostFileForExistingAttachment(string id, string attachmentId) + { + if (!Request?.ContentType.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid content."); + } + + var userId = _userService.GetProperUserId(User).Value; + var cipher = await _cipherRepository.GetByIdAsync(new Guid(id), userId); + var attachments = cipher?.GetAttachments(); + if (attachments == null || !attachments.ContainsKey(attachmentId)) + { + throw new NotFoundException(); + } + var attachmentData = attachments[attachmentId]; + + await Request.GetFileAsync(async (stream) => + { + await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData); + }); + } + [HttpPost("{id}/attachment")] [RequestSizeLimit(105_906_176)] [DisableFormValueModelBinding] @@ -616,20 +699,7 @@ namespace Bit.Api.Controllers { var userId = _userService.GetProperUserId(User).Value; var cipher = await _cipherRepository.GetByIdAsync(new Guid(id), userId); - var attachments = cipher.GetAttachments(); - - if (!attachments.ContainsKey(attachmentId)) - { - throw new NotFoundException(); - } - - var data = attachments[attachmentId]; - var response = new AttachmentResponseModel(attachmentId, data, cipher, _globalSettings) - { - Url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data) - }; - - return response; + return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); } [HttpPost("{id}/attachment/{attachmentId}/share")] @@ -684,6 +754,44 @@ namespace Bit.Api.Controllers await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); } + [AllowAnonymous] + [HttpPost("attachment/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = eventGridEvent.Subject.Split($"{AzureAttachmentStorageService.EventGridEnabledContainerName}/blobs/")[1]; + var (cipherId, organizationId, attachmentId) = AzureAttachmentStorageService.IdentifiersFromBlobName(blobName); + var cipher = await _cipherRepository.GetByIdAsync(new Guid(cipherId)); + var attachments = cipher?.GetAttachments() ?? new Dictionary(); + + if (cipher == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + { + if (_attachmentStorageService is AzureSendFileStorageService azureFileStorageService) + { + await azureFileStorageService.DeleteBlobAsync(blobName); + } + + return; + } + + await _cipherService.ValidateCipherAttachmentFile(cipher, attachments[attachmentId]); + } + catch (Exception e) + { + _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonConvert.SerializeObject(eventGridEvent)}"); + return; + } + } + } + }); + } + private void ValidateAttachment() { if (!Request?.ContentType.Contains("multipart/") ?? true) diff --git a/src/Api/Controllers/EmergencyAccessController.cs b/src/Api/Controllers/EmergencyAccessController.cs index dffdfcd6dc..30b8b787b4 100644 --- a/src/Api/Controllers/EmergencyAccessController.cs +++ b/src/Api/Controllers/EmergencyAccessController.cs @@ -163,5 +163,12 @@ namespace Bit.Api.Controllers var user = await _userService.GetUserByPrincipalAsync(User); return await _emergencyAccessService.ViewAsync(new Guid(id), user); } + + [HttpGet("{id}/{cipherId}/attachment/{attachmentId}")] + public async Task GetAttachmentData(string id, string cipherId, string attachmentId) + { + var user = await _userService.GetUserByPrincipalAsync(User); + return await _emergencyAccessService.GetAttachmentDownloadAsync(new Guid(id), cipherId, attachmentId, user); + } } } diff --git a/src/Api/Controllers/SendsController.cs b/src/Api/Controllers/SendsController.cs index cb812be2bc..438a3db62b 100644 --- a/src/Api/Controllers/SendsController.cs +++ b/src/Api/Controllers/SendsController.cs @@ -190,6 +190,11 @@ namespace Bit.Api.Controllers throw new BadRequestException("Invalid content. File size hint is required."); } + if (model.FileLength.Value > SendService.MAX_FILE_SIZE) + { + throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}."); + } + var userId = _userService.GetProperUserId(User).Value; var (send, data) = model.ToSend(userId, model.File.FileName, _sendService); var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value); @@ -240,7 +245,7 @@ namespace Bit.Api.Controllers } var send = await _sendRepository.GetByIdAsync(new Guid(id)); - await Request.GetSendFileAsync(async (stream) => + await Request.GetFileAsync(async (stream) => { await _sendService.UploadFileToExistingSendAsync(stream, send); }); @@ -248,7 +253,7 @@ namespace Bit.Api.Controllers [AllowAnonymous] [HttpPost("file/validate/azure")] - public async Task AzureValidateFile() + public async Task AzureValidateFile() { return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> { @@ -262,6 +267,10 @@ namespace Bit.Api.Controllers var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); if (send == null) { + if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) + { + await azureSendFileStorageService.DeleteBlobAsync(blobName); + } return; } await _sendService.ValidateSendFile(send); diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 0cd132ae23..a0ebc051e2 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -50,6 +50,12 @@ namespace Bit.Api // Data Protection services.AddCustomDataProtectionServices(Environment, globalSettings); + // Event Grid + if (!string.IsNullOrWhiteSpace(globalSettings.EventGridKey)) + { + ApiHelpers.EventGridKey = globalSettings.EventGridKey; + } + // Stripe Billing StripeConfiguration.ApiKey = globalSettings.StripeApiKey; diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index 20ed178762..689b2e1e87 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -12,6 +12,7 @@ namespace Bit.Api.Utilities { public static class ApiHelpers { + public static string EventGridKey { get; set; } public async static Task ReadJsonFileFromBody(HttpContext httpContext, IFormFile file, long maxSize = 51200) { T obj = default(T); @@ -42,9 +43,16 @@ namespace Bit.Api.Utilities /// Dictionary of eventType strings and their associated handlers. /// OkObjectResult /// Reference https://docs.microsoft.com/en-us/azure/event-grid/receive-events - public async static Task HandleAzureEvents(HttpRequest request, + public async static Task HandleAzureEvents(HttpRequest request, Dictionary> eventTypeHandlers) { + var queryKey = request.Query["key"]; + + if (queryKey != EventGridKey) + { + return new UnauthorizedObjectResult("Authentication failed. Please use a valid key."); + } + var response = string.Empty; var requestContent = await new StreamReader(request.Body).ReadToEndAsync(); if (string.IsNullOrWhiteSpace(requestContent)) diff --git a/src/Api/Utilities/MultipartFormDataHelper.cs b/src/Api/Utilities/MultipartFormDataHelper.cs index 01c4e35823..b7313216f1 100644 --- a/src/Api/Utilities/MultipartFormDataHelper.cs +++ b/src/Api/Utilities/MultipartFormDataHelper.cs @@ -108,7 +108,7 @@ namespace Bit.Api.Utilities } } - public static async Task GetSendFileAsync(this HttpRequest request, Func callback) + public static async Task GetFileAsync(this HttpRequest request, Func callback) { var boundary = GetBoundary(MediaTypeHeaderValue.Parse(request.ContentType), _defaultFormOptions.MultipartBoundaryLengthLimit); diff --git a/src/Core/Models/Api/Request/AttachmentRequestModel.cs b/src/Core/Models/Api/Request/AttachmentRequestModel.cs new file mode 100644 index 0000000000..85ed56cf7f --- /dev/null +++ b/src/Core/Models/Api/Request/AttachmentRequestModel.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Models.Api +{ + public class AttachmentRequestModel + { + public string Key { get; set; } + public string FileName { get; set; } + public long FileSize { get; set; } + public bool AdminRequest { get; set; } = false; + } +} diff --git a/src/Core/Models/Api/Response/AttachmentUploadDataResponseModel.cs b/src/Core/Models/Api/Response/AttachmentUploadDataResponseModel.cs new file mode 100644 index 0000000000..b02f05d211 --- /dev/null +++ b/src/Core/Models/Api/Response/AttachmentUploadDataResponseModel.cs @@ -0,0 +1,15 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.Api +{ + public class AttachmentUploadDataResponseModel : ResponseModel + { + public string AttachmentId { get; set; } + public string Url { get; set; } + public FileUploadType FileUploadType { get; set; } + public CipherResponseModel CipherResponse { get; set; } + public CipherMiniResponseModel CipherMiniResponse { get; set; } + + public AttachmentUploadDataResponseModel() : base("attachment-fileUpload") { } + } +} diff --git a/src/Core/Models/Data/CipherAttachment.cs b/src/Core/Models/Data/CipherAttachment.cs index ea6788e05b..29886537bf 100644 --- a/src/Core/Models/Data/CipherAttachment.cs +++ b/src/Core/Models/Data/CipherAttachment.cs @@ -34,6 +34,7 @@ namespace Bit.Core.Models.Data public string Key { get; set; } public string ContainerName { get; set; } = "attachments"; + public bool Validated { get; set; } = true; // This is stored alongside metadata as an identifier. It does not need repeating in serialization [JsonIgnore] diff --git a/src/Core/Services/IAttachmentStorageService.cs b/src/Core/Services/IAttachmentStorageService.cs index 6dbe0b4fe3..e4a2654802 100644 --- a/src/Core/Services/IAttachmentStorageService.cs +++ b/src/Core/Services/IAttachmentStorageService.cs @@ -1,5 +1,6 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Table; +using Bit.Core.Enums; using System; using System.IO; using System.Threading.Tasks; @@ -8,15 +9,18 @@ namespace Bit.Core.Services { public interface IAttachmentStorageService { - Task UploadNewAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment); - Task UploadShareAttachmentAsync(Stream stream, Guid cipherId, Guid organizationId, CipherAttachment.MetaData attachment); + FileUploadType FileUploadType { get; } + Task UploadNewAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentData); + Task UploadShareAttachmentAsync(Stream stream, Guid cipherId, Guid organizationId, CipherAttachment.MetaData attachmentData); Task StartShareAttachmentAsync(Guid cipherId, Guid organizationId, CipherAttachment.MetaData attachmentData); Task RollbackShareAttachmentAsync(Guid cipherId, Guid organizationId, CipherAttachment.MetaData attachmentData, string originalContainer); Task CleanupAsync(Guid cipherId); - Task DeleteAttachmentAsync(Guid cipherId, CipherAttachment.MetaData attachment); + Task DeleteAttachmentAsync(Guid cipherId, CipherAttachment.MetaData attachmentData); Task DeleteAttachmentsForCipherAsync(Guid cipherId); Task DeleteAttachmentsForOrganizationAsync(Guid organizationId); Task DeleteAttachmentsForUserAsync(Guid userId); + Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData); Task GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData); + Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway); } } diff --git a/src/Core/Services/ICipherService.cs b/src/Core/Services/ICipherService.cs index 95b1468ec4..cdbd32e987 100644 --- a/src/Core/Services/ICipherService.cs +++ b/src/Core/Services/ICipherService.cs @@ -4,6 +4,8 @@ using Bit.Core.Models.Table; using Core.Models.Data; using System; using System.IO; +using Bit.Core.Models.Api; +using Bit.Core.Models.Data; namespace Bit.Core.Services { @@ -13,6 +15,8 @@ namespace Bit.Core.Services bool skipPermissionCheck = false, bool limitCollectionScope = true); Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, IEnumerable collectionIds = null, bool skipPermissionCheck = false); + Task<(string attachmentId, string uploadUrl)> CreateAttachmentForDelayedUploadAsync(Cipher cipher, + AttachmentRequestModel request, Guid savingUserId); Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength, Guid savingUserId, bool orgAdmin = false); Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, long requestLength, string attachmentId, @@ -37,5 +41,8 @@ namespace Bit.Core.Services Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false); Task RestoreManyAsync(IEnumerable ciphers, Guid restoringUserId); + Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); + Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); + Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); } } diff --git a/src/Core/Services/IEmergencyAccessService.cs b/src/Core/Services/IEmergencyAccessService.cs index aa28d0021b..a8042e89a7 100644 --- a/src/Core/Services/IEmergencyAccessService.cs +++ b/src/Core/Services/IEmergencyAccessService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Bit.Core.Enums; +using Bit.Core.Models.Api; using Bit.Core.Models.Api.Response; using Bit.Core.Models.Data; using Bit.Core.Models.Table; @@ -26,5 +27,6 @@ namespace Bit.Core.Services Task SendNotificationsAsync(); Task HandleTimedOutRequestsAsync(); Task ViewAsync(Guid id, User user); + Task GetAttachmentDownloadAsync(Guid id, string cipherId, string attachmentId, User user); } } diff --git a/src/Core/Services/Implementations/AzureAttachmentStorageService.cs b/src/Core/Services/Implementations/AzureAttachmentStorageService.cs index f84454f270..c4147a01a2 100644 --- a/src/Core/Services/Implementations/AzureAttachmentStorageService.cs +++ b/src/Core/Services/Implementations/AzureAttachmentStorageService.cs @@ -7,16 +7,44 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Table; using Bit.Core.Settings; using System.Collections.Generic; +using Bit.Core.Enums; namespace Bit.Core.Services { public class AzureAttachmentStorageService : IAttachmentStorageService { + public FileUploadType FileUploadType => FileUploadType.Azure; + public const string EventGridEnabledContainerName = "attachments-v2"; private const string _defaultContainerName = "attachments"; private readonly static string[] _attachmentContainerName = { "attachments", "attachments-v2" }; - private static readonly TimeSpan downloadLinkLiveTime = TimeSpan.FromMinutes(1); + private static readonly TimeSpan blobLinkLiveTime = TimeSpan.FromMinutes(1); private readonly CloudBlobClient _blobClient; private readonly Dictionary _attachmentContainers = new Dictionary(); + private string BlobName(Guid cipherId, CipherAttachment.MetaData attachmentData, Guid? organizationId = null, bool temp = false) => + string.Concat( + temp ? "temp/" : "", + $"{cipherId}/", + organizationId != null ? $"{organizationId.Value}/" : "", + attachmentData.AttachmentId + ); + + public static (string cipherId, string organizationId, string attachmentId) IdentifiersFromBlobName(string blobName) { + var parts = blobName.Split('/'); + switch (parts.Length) { + case 4: + return (parts[1], parts[2], parts[3]); + case 3: + if (parts[0] == "temp") { + return (parts[1], null, parts[2]); + } else { + return (parts[0], parts[1], parts[2]); + } + case 2: + return (parts[0], null, parts[1]); + default: + throw new Exception("Cannot determine cipher information from blob name"); + } + } public AzureAttachmentStorageService( GlobalSettings globalSettings) @@ -28,21 +56,35 @@ namespace Bit.Core.Services public async Task GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) { await InitAsync(attachmentData.ContainerName); - var blob = _attachmentContainers[attachmentData.ContainerName].GetBlockBlobReference($"{cipher.Id}/{attachmentData.AttachmentId}"); + var blob = _attachmentContainers[attachmentData.ContainerName].GetBlockBlobReference(BlobName(cipher.Id, attachmentData)); var accessPolicy = new SharedAccessBlobPolicy() { - SharedAccessExpiryTime = DateTime.UtcNow.Add(downloadLinkLiveTime), + SharedAccessExpiryTime = DateTime.UtcNow.Add(blobLinkLiveTime), Permissions = SharedAccessBlobPermissions.Read }; return blob.Uri + blob.GetSharedAccessSignature(accessPolicy); } - public async Task UploadNewAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment) + public async Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) { - attachment.ContainerName = _defaultContainerName; + await InitAsync(EventGridEnabledContainerName); + var blob = _attachmentContainers[EventGridEnabledContainerName].GetBlockBlobReference(BlobName(cipher.Id, attachmentData)); + attachmentData.ContainerName = EventGridEnabledContainerName; + var accessPolicy = new SharedAccessBlobPolicy() + { + SharedAccessExpiryTime = DateTime.UtcNow.Add(blobLinkLiveTime), + Permissions = SharedAccessBlobPermissions.Create | SharedAccessBlobPermissions.Write, + }; + + return blob.Uri + blob.GetSharedAccessSignature(accessPolicy); + } + + public async Task UploadNewAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentData) + { + attachmentData.ContainerName = _defaultContainerName; await InitAsync(_defaultContainerName); - var blob = _attachmentContainers[_defaultContainerName].GetBlockBlobReference($"{cipher.Id}/{attachment.AttachmentId}"); + var blob = _attachmentContainers[_defaultContainerName].GetBlockBlobReference(BlobName(cipher.Id, attachmentData)); blob.Metadata.Add("cipherId", cipher.Id.ToString()); if (cipher.UserId.HasValue) { @@ -52,7 +94,7 @@ namespace Bit.Core.Services { blob.Metadata.Add("organizationId", cipher.OrganizationId.Value.ToString()); } - blob.Properties.ContentDisposition = $"attachment; filename=\"{attachment.AttachmentId}\""; + blob.Properties.ContentDisposition = $"attachment; filename=\"{attachmentData.AttachmentId}\""; await blob.UploadFromStreamAsync(stream); } @@ -60,7 +102,8 @@ namespace Bit.Core.Services { attachmentData.ContainerName = _defaultContainerName; await InitAsync(_defaultContainerName); - var blob = _attachmentContainers[_defaultContainerName].GetBlockBlobReference($"temp/{cipherId}/{organizationId}/{attachmentData.AttachmentId}"); + var blob = _attachmentContainers[_defaultContainerName].GetBlockBlobReference( + BlobName(cipherId, attachmentData, organizationId, temp: true)); blob.Metadata.Add("cipherId", cipherId.ToString()); blob.Metadata.Add("organizationId", organizationId.ToString()); blob.Properties.ContentDisposition = $"attachment; filename=\"{attachmentData.AttachmentId}\""; @@ -70,20 +113,22 @@ namespace Bit.Core.Services public async Task StartShareAttachmentAsync(Guid cipherId, Guid organizationId, CipherAttachment.MetaData data) { await InitAsync(data.ContainerName); - var source = _attachmentContainers[data.ContainerName].GetBlockBlobReference($"temp/{cipherId}/{organizationId}/{data.AttachmentId}"); + var source = _attachmentContainers[data.ContainerName].GetBlockBlobReference( + BlobName(cipherId, data, organizationId, temp: true)); if (!(await source.ExistsAsync())) { return; } await InitAsync(_defaultContainerName); - var dest = _attachmentContainers[_defaultContainerName].GetBlockBlobReference($"{cipherId}/{data.AttachmentId}"); + var dest = _attachmentContainers[_defaultContainerName].GetBlockBlobReference(BlobName(cipherId, data)); if (!(await dest.ExistsAsync())) { return; } - var original = _attachmentContainers[_defaultContainerName].GetBlockBlobReference($"temp/{cipherId}/{data.AttachmentId}"); + var original = _attachmentContainers[_defaultContainerName].GetBlockBlobReference( + BlobName(cipherId, data, temp: true)); await original.DeleteIfExistsAsync(); await original.StartCopyAsync(dest); @@ -94,30 +139,83 @@ namespace Bit.Core.Services public async Task RollbackShareAttachmentAsync(Guid cipherId, Guid organizationId, CipherAttachment.MetaData attachmentData, string originalContainer) { await InitAsync(attachmentData.ContainerName); - var source = _attachmentContainers[attachmentData.ContainerName].GetBlockBlobReference($"temp/{cipherId}/{organizationId}/{attachmentData.AttachmentId}"); + var source = _attachmentContainers[attachmentData.ContainerName].GetBlockBlobReference( + BlobName(cipherId, attachmentData, organizationId, temp: true)); await source.DeleteIfExistsAsync(); await InitAsync(originalContainer); - var original = _attachmentContainers[originalContainer].GetBlockBlobReference($"temp/{cipherId}/{attachmentData.AttachmentId}"); + var original = _attachmentContainers[originalContainer].GetBlockBlobReference( + BlobName(cipherId, attachmentData, temp: true)); if (!(await original.ExistsAsync())) { return; } - var dest = _attachmentContainers[originalContainer].GetBlockBlobReference($"{cipherId}/{attachmentData.AttachmentId}"); + var dest = _attachmentContainers[originalContainer].GetBlockBlobReference( + BlobName(cipherId, attachmentData)); await dest.DeleteIfExistsAsync(); await dest.StartCopyAsync(original); await original.DeleteIfExistsAsync(); } - public async Task DeleteAttachmentAsync(Guid cipherId, CipherAttachment.MetaData attachment) + public async Task DeleteAttachmentAsync(Guid cipherId, CipherAttachment.MetaData attachmentData) { - await InitAsync(attachment.ContainerName); - var blobName = $"{cipherId}/{attachment.AttachmentId}"; - var blob = _attachmentContainers[attachment.ContainerName].GetBlockBlobReference(blobName); + await InitAsync(attachmentData.ContainerName); + var blob = _attachmentContainers[attachmentData.ContainerName].GetBlockBlobReference( + BlobName(cipherId, attachmentData)); await blob.DeleteIfExistsAsync(); } + public async Task CleanupAsync(Guid cipherId) => await DeleteAttachmentsForPathAsync($"temp/{cipherId}"); + + public async Task DeleteAttachmentsForCipherAsync(Guid cipherId) => + await DeleteAttachmentsForPathAsync(cipherId.ToString()); + + public async Task DeleteAttachmentsForOrganizationAsync(Guid organizationId) + { + await InitAsync(_defaultContainerName); + } + + public async Task DeleteAttachmentsForUserAsync(Guid userId) + { + await InitAsync(_defaultContainerName); + } + + public async Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway) + { + await InitAsync(attachmentData.ContainerName); + + var blob = _attachmentContainers[attachmentData.ContainerName].GetBlockBlobReference(BlobName(cipher.Id, attachmentData)); + + if (!blob.Exists()) + { + return (false, null); + } + + blob.FetchAttributes(); + + blob.Metadata["cipherId"] = cipher.Id.ToString(); + if (cipher.UserId.HasValue) + { + blob.Metadata["userId"] = cipher.UserId.Value.ToString(); + } + else + { + blob.Metadata["organizationId"] = cipher.OrganizationId.Value.ToString(); + } + blob.Properties.ContentDisposition = $"attachment; filename=\"{attachmentData.AttachmentId}\""; + blob.SetMetadata(); + blob.SetProperties(); + + var length = blob.Properties.Length; + if (length < attachmentData.Size - leeway || length > attachmentData.Size + leeway) + { + return (false, length); + } + + return (true, length); + } + private async Task DeleteAttachmentsForPathAsync(string path) { foreach (var container in _attachmentContainerName) @@ -145,21 +243,6 @@ namespace Bit.Core.Services } } - - public async Task CleanupAsync(Guid cipherId) => await DeleteAttachmentsForPathAsync($"temp/{cipherId}"); - - public async Task DeleteAttachmentsForCipherAsync(Guid cipherId) => await DeleteAttachmentsForPathAsync(cipherId.ToString()); - - public async Task DeleteAttachmentsForOrganizationAsync(Guid organizationId) - { - await InitAsync(_defaultContainerName); - } - - public async Task DeleteAttachmentsForUserAsync(Guid userId) - { - await InitAsync(_defaultContainerName); - } - private async Task InitAsync(string containerName) { if (!_attachmentContainers.ContainsKey(containerName) || _attachmentContainers[containerName] == null) diff --git a/src/Core/Services/Implementations/AzureSendFileStorageService.cs b/src/Core/Services/Implementations/AzureSendFileStorageService.cs index 62f998d72c..7009878c94 100644 --- a/src/Core/Services/Implementations/AzureSendFileStorageService.cs +++ b/src/Core/Services/Implementations/AzureSendFileStorageService.cs @@ -44,10 +44,12 @@ namespace Bit.Core.Services await blob.UploadFromStreamAsync(stream); } - public async Task DeleteFileAsync(Send send, string fileId) + public async Task DeleteFileAsync(Send send, string fileId) => await DeleteBlobAsync(BlobName(send, fileId)); + + public async Task DeleteBlobAsync(string blobName) { await InitAsync(); - var blob = _sendFilesContainer.GetBlockBlobReference(BlobName(send, fileId)); + var blob = _sendFilesContainer.GetBlockBlobReference(blobName); await blob.DeleteIfExistsAsync(); } diff --git a/src/Core/Services/Implementations/CipherService.cs b/src/Core/Services/Implementations/CipherService.cs index c781108f8e..220a29dfef 100644 --- a/src/Core/Services/Implementations/CipherService.cs +++ b/src/Core/Services/Implementations/CipherService.cs @@ -12,11 +12,14 @@ using System.IO; using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.Core.Settings; +using Bit.Core.Models.Api; namespace Bit.Core.Services { public class CipherService : ICipherService { + public const long MAX_FILE_SIZE = 500L * 1024L * 1024L; // 500MB + public const string MAX_FILE_SIZE_READABLE = "500 MB"; private readonly ICipherRepository _cipherRepository; private readonly IFolderRepository _folderRepository; private readonly ICollectionRepository _collectionRepository; @@ -30,6 +33,7 @@ namespace Bit.Core.Services private readonly IUserService _userService; private readonly IPolicyRepository _policyRepository; private readonly GlobalSettings _globalSettings; + private const long _fileSizeLeeway = 1024L * 1024L; // 1MB public CipherService( ICipherRepository cipherRepository, @@ -130,7 +134,7 @@ namespace Bit.Core.Services { var org = await _organizationUserRepository.GetDetailsByUserAsync(savingUserId, policy.OrganizationId, OrganizationUserStatusType.Confirmed); - if(org != null && org.Enabled && org.UsePolicies + if (org != null && org.Enabled && org.UsePolicies && org.Type != OrganizationUserType.Admin && org.Type != OrganizationUserType.Owner) { throw new BadRequestException("Due to an Enterprise Policy, you are restricted from saving items to your personal vault."); @@ -166,55 +170,56 @@ namespace Bit.Core.Services } } + public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment) + { + if (attachment == null) + { + throw new BadRequestException("Cipher attachment does not exist"); + } + + await _attachmentStorageService.UploadNewAttachmentAsync(stream, cipher, attachment); + + if (!await ValidateCipherAttachmentFile(cipher, attachment)) + { + throw new BadRequestException("File received does not match expected file length."); + } + } + + public async Task<(string attachmentId, string uploadUrl)> CreateAttachmentForDelayedUploadAsync(Cipher cipher, + AttachmentRequestModel request, Guid savingUserId) + { + await ValidateCipherEditForAttachmentAsync(cipher, savingUserId, request.AdminRequest, request.FileSize); + + var attachmentId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); + var data = new CipherAttachment.MetaData + { + AttachmentId = attachmentId, + FileName = request.FileName, + Key = request.Key, + Size = request.FileSize, + Validated = false, + }; + + var uploadUrl = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, data); + + await _cipherRepository.UpdateAttachmentAsync(new CipherAttachment + { + Id = cipher.Id, + UserId = cipher.UserId, + OrganizationId = cipher.OrganizationId, + AttachmentId = attachmentId, + AttachmentData = JsonConvert.SerializeObject(data) + }); + cipher.AddAttachment(attachmentId, data); + await _pushService.PushSyncCipherUpdateAsync(cipher, null); + + return (attachmentId, uploadUrl); + } + public async Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength, Guid savingUserId, bool orgAdmin = false) { - if (!orgAdmin && !(await UserCanEditAsync(cipher, savingUserId))) - { - throw new BadRequestException("You do not have permissions to edit this."); - } - - if (requestLength < 1) - { - throw new BadRequestException("No data to attach."); - } - - var storageBytesRemaining = 0L; - if (cipher.UserId.HasValue) - { - var user = await _userRepository.GetByIdAsync(cipher.UserId.Value); - if (!(await _userService.CanAccessPremium(user))) - { - throw new BadRequestException("You must have premium status to use attachments."); - } - - if (user.Premium) - { - storageBytesRemaining = user.StorageBytesRemaining(); - } - else - { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? (short)10240 : (short)1); - } - } - else if (cipher.OrganizationId.HasValue) - { - var org = await _organizationRepository.GetByIdAsync(cipher.OrganizationId.Value); - if (!org.MaxStorageGb.HasValue) - { - throw new BadRequestException("This organization cannot use attachments."); - } - - storageBytesRemaining = org.StorageBytesRemaining(); - } - - if (storageBytesRemaining < requestLength) - { - throw new BadRequestException("Not enough storage available."); - } + await ValidateCipherEditForAttachmentAsync(cipher, savingUserId, orgAdmin, requestLength); var attachmentId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); var data = new CipherAttachment.MetaData @@ -242,6 +247,10 @@ namespace Bit.Core.Services await _cipherRepository.UpdateAttachmentAsync(attachment); await _eventService.LogCipherEventAsync(cipher, Enums.EventType.Cipher_AttachmentCreated); cipher.AddAttachment(attachmentId, data); + + if (!await ValidateCipherAttachmentFile(cipher, data)) { + throw new Exception("Content-Length does not match uploaded file size"); + } } catch { @@ -314,6 +323,56 @@ namespace Bit.Core.Services } } + public async Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + var (valid, realSize) = await _attachmentStorageService.ValidateFileAsync(cipher, attachmentData, _fileSizeLeeway); + + if (!valid || realSize > MAX_FILE_SIZE) + { + // File reported differs in size from that promised. Must be a rogue client. Delete Send + await DeleteAttachmentAsync(cipher, attachmentData); + return false; + } + // Update Send data if necessary + if (realSize != attachmentData.Size) + { + attachmentData.Size = realSize.Value; + } + attachmentData.Validated = true; + + var updatedAttachment = new CipherAttachment + { + Id = cipher.Id, + UserId = cipher.UserId, + OrganizationId = cipher.OrganizationId, + AttachmentId = attachmentData.AttachmentId, + AttachmentData = JsonConvert.SerializeObject(attachmentData) + }; + + + await _cipherRepository.UpdateAttachmentAsync(updatedAttachment); + + return valid; + } + + public async Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId) + { + var attachments = cipher?.GetAttachments() ?? new Dictionary(); + + if (!attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + { + throw new NotFoundException(); + } + + var data = attachments[attachmentId]; + var response = new AttachmentResponseModel(attachmentId, data, cipher, _globalSettings) + { + Url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data) + }; + + return response; + } + public async Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false) { if (!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId))) @@ -371,14 +430,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - var data = cipher.GetAttachments()[attachmentId]; - await _cipherRepository.DeleteAttachmentAsync(cipher.Id, attachmentId); - cipher.DeleteAttachment(attachmentId); - await _attachmentStorageService.DeleteAttachmentAsync(cipher.Id, data); - await _eventService.LogCipherEventAsync(cipher, Enums.EventType.Cipher_AttachmentDeleted); - - // push - await _pushService.PushSyncCipherUpdateAsync(cipher, null); + await DeleteAttachmentAsync(cipher, cipher.GetAttachments()[attachmentId]); } public async Task PurgeAsync(Guid organizationId) @@ -765,7 +817,7 @@ namespace Bit.Core.Services { var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList(); - await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); + await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); } var events = deletingCiphers.Select(c => @@ -852,5 +904,79 @@ namespace Bit.Core.Services ); } } + + private async Task DeleteAttachmentAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + if (attachmentData == null || string.IsNullOrWhiteSpace(attachmentData.AttachmentId)) + { + return; + } + + await _cipherRepository.DeleteAttachmentAsync(cipher.Id, attachmentData.AttachmentId); + cipher.DeleteAttachment(attachmentData.AttachmentId); + await _attachmentStorageService.DeleteAttachmentAsync(cipher.Id, attachmentData); + await _eventService.LogCipherEventAsync(cipher, Enums.EventType.Cipher_AttachmentDeleted); + + // push + await _pushService.PushSyncCipherUpdateAsync(cipher, null); + } + + private async Task ValidateCipherEditForAttachmentAsync(Cipher cipher, Guid savingUserId, bool orgAdmin, + long requestLength) + { + if (!orgAdmin && !(await UserCanEditAsync(cipher, savingUserId))) + { + throw new BadRequestException("You do not have permissions to edit this."); + } + + if (requestLength < 1) + { + throw new BadRequestException("No data to attach."); + } + + var storageBytesRemaining = await StorageBytesRemainingForCipherAsync(cipher); + + if (storageBytesRemaining < requestLength) + { + throw new BadRequestException("Not enough storage available."); + } + } + + private async Task StorageBytesRemainingForCipherAsync(Cipher cipher) + { + var storageBytesRemaining = 0L; + if (cipher.UserId.HasValue) + { + var user = await _userRepository.GetByIdAsync(cipher.UserId.Value); + if (!(await _userService.CanAccessPremium(user))) + { + throw new BadRequestException("You must have premium status to use attachments."); + } + + if (user.Premium) + { + storageBytesRemaining = user.StorageBytesRemaining(); + } + else + { + // Users that get access to file storage/premium from their organization get the default + // 1 GB max storage. + storageBytesRemaining = user.StorageBytesRemaining( + _globalSettings.SelfHosted ? (short)10240 : (short)1); + } + } + else if (cipher.OrganizationId.HasValue) + { + var org = await _organizationRepository.GetByIdAsync(cipher.OrganizationId.Value); + if (!org.MaxStorageGb.HasValue) + { + throw new BadRequestException("This organization cannot use attachments."); + } + + storageBytesRemaining = org.StorageBytesRemaining(); + } + + return storageBytesRemaining; + } } } diff --git a/src/Core/Services/Implementations/EmergencyAccessService.cs b/src/Core/Services/Implementations/EmergencyAccessService.cs index efcd86a6e1..d6a86c1ca8 100644 --- a/src/Core/Services/Implementations/EmergencyAccessService.cs +++ b/src/Core/Services/Implementations/EmergencyAccessService.cs @@ -13,6 +13,7 @@ using Bit.Core.Utilities; using Bit.Core.Settings; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; +using Bit.Core.Models.Api; namespace Bit.Core.Services { @@ -23,6 +24,7 @@ namespace Bit.Core.Services private readonly IUserRepository _userRepository; private readonly ICipherRepository _cipherRepository; private readonly IPolicyRepository _policyRepository; + private readonly ICipherService _cipherService; private readonly IMailService _mailService; private readonly IUserService _userService; private readonly IDataProtector _dataProtector; @@ -36,6 +38,7 @@ namespace Bit.Core.Services IUserRepository userRepository, ICipherRepository cipherRepository, IPolicyRepository policyRepository, + ICipherService cipherService, IMailService mailService, IUserService userService, IPasswordHasher passwordHasher, @@ -48,6 +51,7 @@ namespace Bit.Core.Services _userRepository = userRepository; _cipherRepository = cipherRepository; _policyRepository = policyRepository; + _cipherService = cipherService; _mailService = mailService; _userService = userService; _passwordHasher = passwordHasher; @@ -352,6 +356,19 @@ namespace Bit.Core.Services return new EmergencyAccessViewResponseModel(_globalSettings, emergencyAccess, ciphers); } + public async Task GetAttachmentDownloadAsync(Guid id, string cipherId, string attachmentId, User requestingUser) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View)) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var cipher = await _cipherRepository.GetByIdAsync(new Guid(cipherId), emergencyAccess.GrantorId); + return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); + } + private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName) { var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); diff --git a/src/Core/Services/Implementations/LocalAttachmentStorageService.cs b/src/Core/Services/Implementations/LocalAttachmentStorageService.cs index d02c62db6b..d742931a5c 100644 --- a/src/Core/Services/Implementations/LocalAttachmentStorageService.cs +++ b/src/Core/Services/Implementations/LocalAttachmentStorageService.cs @@ -4,6 +4,7 @@ using System; using Bit.Core.Models.Table; using Bit.Core.Models.Data; using Bit.Core.Settings; +using Bit.Core.Enums; namespace Bit.Core.Services { @@ -13,6 +14,8 @@ namespace Bit.Core.Services private readonly string _baseDirPath; private readonly string _baseTempDirPath; + public FileUploadType FileUploadType => FileUploadType.Direct; + public LocalAttachmentStorageService( IGlobalSettings globalSettings) { @@ -173,6 +176,25 @@ namespace Bit.Core.Services organizationId.HasValue ? AttachmentFilePath(OrganizationDirectoryPath(cipherId, organizationId.Value, temp), attachmentId) : AttachmentFilePath(CipherDirectoryPath(cipherId, temp), attachmentId); + public Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + => Task.FromResult($"{cipher.Id}/attachment/{attachmentData.AttachmentId}"); + public Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway) + { + long? length = null; + var path = AttachmentFilePath(attachmentData.AttachmentId, cipher.Id, temp: false); + if (!File.Exists(path)) + { + return Task.FromResult((false, length)); + } + + length = new FileInfo(path).Length; + if (attachmentData.Size < length - leeway || attachmentData.Size > length + leeway) + { + return Task.FromResult((false, length)); + } + + return Task.FromResult((true, length)); + } } } diff --git a/src/Core/Services/Implementations/SendService.cs b/src/Core/Services/Implementations/SendService.cs index 8afbdef675..d0bdb1ea97 100644 --- a/src/Core/Services/Implementations/SendService.cs +++ b/src/Core/Services/Implementations/SendService.cs @@ -17,6 +17,8 @@ namespace Bit.Core.Services { public class SendService : ISendService { + public const long MAX_FILE_SIZE = 500L * 1024L * 1024L; // 500MB + public const string MAX_FILE_SIZE_READABLE = "500 MB"; private readonly ISendRepository _sendRepository; private readonly IUserRepository _userRepository; private readonly IPolicyRepository _policyRepository; @@ -142,10 +144,11 @@ namespace Bit.Core.Services var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, _fileSizeLeeway); - if (!valid) + if (!valid || realSize > MAX_FILE_SIZE) { // File reported differs in size from that promised. Must be a rogue client. Delete Send await DeleteSendAsync(send); + return false; } // Update Send data if necessary diff --git a/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs b/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs index 795fc6e9fd..e47f45f968 100644 --- a/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs +++ b/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Table; @@ -8,6 +9,8 @@ namespace Bit.Core.Services { public class NoopAttachmentStorageService : IAttachmentStorageService { + public FileUploadType FileUploadType => FileUploadType.Direct; + public Task CleanupAsync(Guid cipherId) { return Task.FromResult(0); @@ -58,5 +61,13 @@ namespace Bit.Core.Services return Task.FromResult((string)null); } + public Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + return Task.FromResult(default(string)); + } + public Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway) + { + return Task.FromResult((false, (long?)null)); + } } } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 280728ab8d..2f1eab29cc 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -21,6 +21,7 @@ namespace Bit.Core.Settings public virtual bool DisableUserRegistration { get; set; } public virtual bool DisableEmailNewDevice { get; set; } public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days + public virtual string EventGridKey { get; set; } public virtual InstallationSettings Installation { get; set; } = new InstallationSettings(); public virtual BaseServiceUriSettings BaseServiceUri { get; set; } = new BaseServiceUriSettings(); public virtual SqlSettings SqlServer { get; set; } = new SqlSettings();