mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 08:02:49 -05:00
Direct upload to Azure/Local (#1188)
* Direct upload to azure To validate file sizes in the event of a rogue client, Azure event webhooks will be hooked up to AzureValidateFile. Sends outside of a grace size will be deleted as non-compliant. TODO: LocalSendFileStorageService direct upload method/endpoint. * Quick respond to no-body event calls These shouldn't happen, but might if some errant get requests occur * Event Grid only POSTS to webhook * Enable local storage direct file upload * Increase file size difference leeway * Upload through service * Fix LocalFileSendStorage It turns out that multipartHttpStreams do not have a length until read. this causes all long files to be "invalid". We need to write the entire stream, then validate length, just like Azure. the difference is, We can return an exception to local storage admonishing the client for lying * Update src/Api/Utilities/ApiHelpers.cs Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> * Do not delete directory if it has files * Allow large uploads for self hosted instances * Fix formatting * Re-verfiy access and increment access count on download of Send File * Update src/Core/Services/Implementations/SendService.cs Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> * Add back in original Send upload * Update size and mark as validated upon Send file validation * Log azure file validation errors * Lint fix Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com>
This commit is contained in:
8
src/Core/Enums/FileUploadType.cs
Normal file
8
src/Core/Enums/FileUploadType.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Bit.Core.Enums
|
||||
{
|
||||
public enum FileUploadType
|
||||
{
|
||||
Direct = 0,
|
||||
Azure = 1,
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@ namespace Bit.Core.Models.Api
|
||||
public class SendRequestModel
|
||||
{
|
||||
public SendType Type { get; set; }
|
||||
public long? FileLength { get; set; }
|
||||
public long? FileLength { get; set; } = null;
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Name { get; set; }
|
||||
|
@ -0,0 +1,14 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Models.Api.Response
|
||||
{
|
||||
public class SendFileUploadDataResponseModel : ResponseModel
|
||||
{
|
||||
public SendFileUploadDataResponseModel() : base("send-fileUpload") { }
|
||||
|
||||
public string Url { get; set; }
|
||||
public FileUploadType FileUploadType { get; set; }
|
||||
public SendResponseModel SendResponse { get; set; }
|
||||
|
||||
}
|
||||
}
|
@ -33,5 +33,6 @@ namespace Bit.Core.Models.Data
|
||||
|
||||
public string Id { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public bool Validated { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
@ -10,9 +10,11 @@ namespace Bit.Core.Services
|
||||
{
|
||||
Task DeleteSendAsync(Send send);
|
||||
Task SaveSendAsync(Send send);
|
||||
Task CreateSendAsync(Send send, SendFileData data, Stream stream, long requestLength);
|
||||
Task<string> SaveFileSendAsync(Send send, SendFileData data, long fileLength);
|
||||
Task UploadFileToExistingSendAsync(Stream stream, Send send);
|
||||
Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password);
|
||||
string HashPassword(string password);
|
||||
Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password);
|
||||
Task<bool> ValidateSendFile(Send send);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Table;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
@ -7,10 +8,13 @@ namespace Bit.Core.Services
|
||||
{
|
||||
public interface ISendFileStorageService
|
||||
{
|
||||
FileUploadType FileUploadType { get; }
|
||||
Task UploadNewFileAsync(Stream stream, Send send, string fileId);
|
||||
Task DeleteFileAsync(Send send, string fileId);
|
||||
Task DeleteFilesForOrganizationAsync(Guid organizationId);
|
||||
Task DeleteFilesForUserAsync(Guid userId);
|
||||
Task<string> GetSendFileDownloadUrlAsync(Send send, string fileId);
|
||||
Task<string> GetSendFileUploadUrlAsync(Send send, string fileId);
|
||||
Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway);
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ using System.IO;
|
||||
using System;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
@ -15,6 +16,8 @@ namespace Bit.Core.Services
|
||||
private readonly CloudBlobClient _blobClient;
|
||||
private CloudBlobContainer _sendFilesContainer;
|
||||
|
||||
public FileUploadType FileUploadType => FileUploadType.Azure;
|
||||
|
||||
public static string SendIdFromBlobName(string blobName) => blobName.Split('/')[0];
|
||||
public static string BlobName(Send send, string fileId) => $"{send.Id}/{fileId}";
|
||||
|
||||
@ -71,6 +74,54 @@ namespace Bit.Core.Services
|
||||
return blob.Uri + blob.GetSharedAccessSignature(accessPolicy);
|
||||
}
|
||||
|
||||
public async Task<string> GetSendFileUploadUrlAsync(Send send, string fileId)
|
||||
{
|
||||
await InitAsync();
|
||||
var blob = _sendFilesContainer.GetBlockBlobReference(BlobName(send, fileId));
|
||||
|
||||
var accessPolicy = new SharedAccessBlobPolicy()
|
||||
{
|
||||
SharedAccessExpiryTime = DateTime.UtcNow.Add(_downloadLinkLiveTime),
|
||||
Permissions = SharedAccessBlobPermissions.Create | SharedAccessBlobPermissions.Write,
|
||||
};
|
||||
|
||||
return blob.Uri + blob.GetSharedAccessSignature(accessPolicy);
|
||||
}
|
||||
|
||||
public async Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
|
||||
{
|
||||
await InitAsync();
|
||||
|
||||
var blob = _sendFilesContainer.GetBlockBlobReference(BlobName(send, fileId));
|
||||
|
||||
if (!blob.Exists())
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
blob.FetchAttributes();
|
||||
|
||||
if (send.UserId.HasValue)
|
||||
{
|
||||
blob.Metadata["userId"] = send.UserId.Value.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
blob.Metadata["organizationId"] = send.OrganizationId.Value.ToString();
|
||||
}
|
||||
blob.Properties.ContentDisposition = $"attachment; filename=\"{fileId}\"";
|
||||
blob.SetMetadata();
|
||||
blob.SetProperties();
|
||||
|
||||
var length = blob.Properties.Length;
|
||||
if (length < expectedFileSize - leeway || length > expectedFileSize + leeway)
|
||||
{
|
||||
return (false, length);
|
||||
}
|
||||
|
||||
return (true, length);
|
||||
}
|
||||
|
||||
private async Task InitAsync()
|
||||
{
|
||||
if (_sendFilesContainer == null)
|
||||
|
@ -4,6 +4,7 @@ using System;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Settings;
|
||||
using System.Linq;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
@ -14,6 +15,7 @@ namespace Bit.Core.Services
|
||||
|
||||
private string RelativeFilePath(Send send, string fileID) => $"{send.Id}/{fileID}";
|
||||
private string FilePath(Send send, string fileID) => $"{_baseDirPath}/{RelativeFilePath(send, fileID)}";
|
||||
public FileUploadType FileUploadType => FileUploadType.Direct;
|
||||
|
||||
public LocalSendStorageService(
|
||||
GlobalSettings globalSettings)
|
||||
@ -83,5 +85,26 @@ namespace Bit.Core.Services
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<string> GetSendFileUploadUrlAsync(Send send, string fileId)
|
||||
=> Task.FromResult($"/sends/{send.Id}/file/{fileId}");
|
||||
|
||||
public Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
|
||||
{
|
||||
long? length = null;
|
||||
var path = FilePath(send, fileId);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.FromResult((false, length));
|
||||
}
|
||||
|
||||
length = new FileInfo(path).Length;
|
||||
if (expectedFileSize < length - leeway || expectedFileSize > length + leeway)
|
||||
{
|
||||
return Task.FromResult((false, length));
|
||||
}
|
||||
|
||||
return Task.FromResult((true, length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ namespace Bit.Core.Services
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private const long _fileSizeLeeway = 1024L * 1024L; // 1MB
|
||||
|
||||
public SendService(
|
||||
ISendRepository sendRepository,
|
||||
@ -74,51 +75,21 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateSendAsync(Send send, SendFileData data, Stream stream, long requestLength)
|
||||
public async Task<string> SaveFileSendAsync(Send send, SendFileData data, long fileLength)
|
||||
{
|
||||
if (send.Type != SendType.File)
|
||||
{
|
||||
throw new BadRequestException("Send is not of type \"file\".");
|
||||
}
|
||||
|
||||
if (requestLength < 1)
|
||||
if (fileLength < 1)
|
||||
{
|
||||
throw new BadRequestException("No file data.");
|
||||
}
|
||||
|
||||
var storageBytesRemaining = 0L;
|
||||
if (send.UserId.HasValue)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(send.UserId.Value);
|
||||
if (!(await _userService.CanAccessPremium(user)))
|
||||
{
|
||||
throw new BadRequestException("You must have premium status to use file sends.");
|
||||
}
|
||||
var storageBytesRemaining = await StorageRemainingForSendAsync(send);
|
||||
|
||||
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 (send.OrganizationId.HasValue)
|
||||
{
|
||||
var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value);
|
||||
if (!org.MaxStorageGb.HasValue)
|
||||
{
|
||||
throw new BadRequestException("This organization cannot use file sends.");
|
||||
}
|
||||
|
||||
storageBytesRemaining = org.StorageBytesRemaining();
|
||||
}
|
||||
|
||||
if (storageBytesRemaining < requestLength)
|
||||
if (storageBytesRemaining < fileLength)
|
||||
{
|
||||
throw new BadRequestException("Not enough storage available.");
|
||||
}
|
||||
@ -128,24 +99,12 @@ namespace Bit.Core.Services
|
||||
try
|
||||
{
|
||||
data.Id = fileId;
|
||||
data.Size = fileLength;
|
||||
data.Validated = false;
|
||||
send.Data = JsonConvert.SerializeObject(data,
|
||||
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
await SaveSendAsync(send);
|
||||
await _sendFileStorageService.UploadNewFileAsync(stream, send, fileId);
|
||||
// Need to save length of stream since that isn't available until it is read
|
||||
if (stream.Length <= requestLength)
|
||||
{
|
||||
data.Size = stream.Length;
|
||||
send.Data = JsonConvert.SerializeObject(data,
|
||||
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
await SaveSendAsync(send);
|
||||
}
|
||||
else
|
||||
{
|
||||
await DeleteSendAsync(send);
|
||||
throw new BadRequestException("Content-Length header is smaller than file received.");
|
||||
}
|
||||
|
||||
return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@ -155,6 +114,53 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UploadFileToExistingSendAsync(Stream stream, Send send)
|
||||
{
|
||||
if (send?.Data == null)
|
||||
{
|
||||
throw new BadRequestException("Send does not have file data");
|
||||
}
|
||||
|
||||
if (send.Type != SendType.File)
|
||||
{
|
||||
throw new BadRequestException("Not a File Type Send.");
|
||||
}
|
||||
|
||||
var data = JsonConvert.DeserializeObject<SendFileData>(send.Data);
|
||||
|
||||
await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id);
|
||||
|
||||
if (!await ValidateSendFile(send))
|
||||
{
|
||||
throw new BadRequestException("File received does not match expected file length.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateSendFile(Send send)
|
||||
{
|
||||
var fileData = JsonConvert.DeserializeObject<SendFileData>(send.Data);
|
||||
|
||||
var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, _fileSizeLeeway);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
// File reported differs in size from that promised. Must be a rogue client. Delete Send
|
||||
await DeleteSendAsync(send);
|
||||
}
|
||||
|
||||
// Update Send data if necessary
|
||||
if (realSize != fileData.Size)
|
||||
{
|
||||
fileData.Size = realSize.Value;
|
||||
}
|
||||
fileData.Validated = true;
|
||||
send.Data = JsonConvert.SerializeObject(fileData,
|
||||
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
await SaveSendAsync(send);
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
public async Task DeleteSendAsync(Send send)
|
||||
{
|
||||
await _sendRepository.DeleteAsync(send);
|
||||
@ -281,5 +287,42 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> StorageRemainingForSendAsync(Send send)
|
||||
{
|
||||
var storageBytesRemaining = 0L;
|
||||
if (send.UserId.HasValue)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(send.UserId.Value);
|
||||
if (!await _userService.CanAccessPremium(user))
|
||||
{
|
||||
throw new BadRequestException("You must have premium status to use file sends.");
|
||||
}
|
||||
|
||||
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 (send.OrganizationId.HasValue)
|
||||
{
|
||||
var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value);
|
||||
if (!org.MaxStorageGb.HasValue)
|
||||
{
|
||||
throw new BadRequestException("This organization cannot use file sends.");
|
||||
}
|
||||
|
||||
storageBytesRemaining = org.StorageBytesRemaining();
|
||||
}
|
||||
|
||||
return storageBytesRemaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,14 @@
|
||||
using System.IO;
|
||||
using System;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
public class NoopSendFileStorageService : ISendFileStorageService
|
||||
{
|
||||
public FileUploadType FileUploadType => FileUploadType.Direct;
|
||||
|
||||
public Task UploadNewFileAsync(Stream stream, Send send, string attachmentId)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
@ -31,5 +34,15 @@ namespace Bit.Core.Services
|
||||
{
|
||||
return Task.FromResult((string)null);
|
||||
}
|
||||
|
||||
public Task<string> GetSendFileUploadUrlAsync(Send send, string fileId)
|
||||
{
|
||||
return Task.FromResult((string)null);
|
||||
}
|
||||
|
||||
public Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
|
||||
{
|
||||
return Task.FromResult((false, default(long?)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user