1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-03 00:52:49 -05:00

Attachment blob upload (#1229)

* Add Cipher attachment upload endpoints

* Add validation bool to attachment storage data

This bool is used to determine whether or not to renew upload links

* Add model to request a new attachment to be made for later upload

* Add model to respond with created attachment.

The two cipher properties represent the two different
cipher model types that can be returned. Cipher Response from
personal items and mini response from organizations

* Create Azure SAS-authorized upload links for both one-shot and block uploads

* Add service methods to handle delayed upload and file size validation

* Add emergency access method for downloading attachments direct from Azure

* Add new attachment storage methods to other services

* Update service interfaces

* Log event grid exceptions

* Limit Send and Attachment Size to 500MB

* capitalize Key property

* Add key validation to Azure Event Grid endpoint

* Delete blob for unexpected blob creation events

* Set Event Grid key at API startup

* Change renew attachment upload url request path to match Send

* Shore up attachment cleanup method.

As long as we have the required information, we should always delete
attachments from each the Repository, the cipher in memory, and the
file storage service to ensure they're all synched.
This commit is contained in:
Matt Gibson
2021-03-30 18:41:14 -05:00
committed by GitHub
parent 908decac5e
commit 022e404cc5
20 changed files with 556 additions and 114 deletions

View File

@ -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<bool> 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<AttachmentResponseModel> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId)
{
var attachments = cipher?.GetAttachments() ?? new Dictionary<string, CipherAttachment.MetaData>();
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<long> 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;
}
}
}