1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-01 23:31:41 +01: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:
Matt Gibson 2021-03-21 23:01:19 -05:00 committed by GitHub
parent fd42b227b3
commit 989d4df599
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 393 additions and 55 deletions

View File

@ -26,6 +26,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.6" />
<PackageReference Include="NewRelic.Agent" Version="8.30.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.5.1" />
<PackageReference Include="Microsoft.Azure.EventGrid" Version="3.2.0" />
</ItemGroup>
<ItemGroup>

View File

@ -7,11 +7,17 @@ using Microsoft.AspNetCore.Authorization;
using Bit.Core.Models.Api;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Api.Utilities;
using Bit.Core.Models.Table;
using Bit.Core.Utilities;
using Bit.Core.Settings;
using Bit.Core.Models.Api.Response;
using Bit.Core.Enums;
using Microsoft.Azure.EventGrid.Models;
using Bit.Api.Utilities;
using System.Collections.Generic;
using Bit.Core.Models.Table;
using Newtonsoft.Json;
using Bit.Core.Models.Data;
using Microsoft.Extensions.Logging;
namespace Bit.Api.Controllers
{
@ -23,6 +29,7 @@ namespace Bit.Api.Controllers
private readonly IUserService _userService;
private readonly ISendService _sendService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings;
public SendsController(
@ -30,12 +37,14 @@ namespace Bit.Api.Controllers
IUserService userService,
ISendService sendService,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
{
_sendRepository = sendRepository;
_userService = userService;
_sendService = sendService;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
}
@ -160,12 +169,113 @@ namespace Bit.Api.Controllers
var userId = _userService.GetProperUserId(User).Value;
var (madeSend, madeData) = model.ToSend(userId, fileName, _sendService);
send = madeSend;
await _sendService.CreateSendAsync(send, madeData, stream, model.FileLength.GetValueOrDefault(0));
await _sendService.SaveFileSendAsync(send, madeData, model.FileLength.GetValueOrDefault(0));
await _sendService.UploadFileToExistingSendAsync(stream, send);
});
return new SendResponseModel(send, _globalSettings);
}
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{
if (model.Type != SendType.File)
{
throw new BadRequestException("Invalid content.");
}
if (!model.FileLength.HasValue)
{
throw new BadRequestException("Invalid content. File size hint is required.");
}
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);
return new SendFileUploadDataResponseModel
{
Url = uploadUrl,
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings)
};
}
[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
var userId = _userService.GetProperUserId(User).Value;
var sendId = new Guid(id);
var send = await _sendRepository.GetByIdAsync(sendId);
var fileData = JsonConvert.DeserializeObject<SendFileData>(send?.Data);
if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) ||
!send.UserId.HasValue || fileData.Id != fileId || fileData.Validated)
{
// Not found if Send isn't found, user doesn't have access, request is faulty,
// or we've already validated the file. This last is to emulate create-only blob permissions for Azure
throw new NotFoundException();
}
return new SendFileUploadDataResponseModel
{
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings),
};
}
[HttpPost("{id}/file/{fileId}")]
[DisableFormValueModelBinding]
public async Task PostFileForExistingSend(string id, string fileId)
{
if (!Request?.ContentType.Contains("multipart/") ?? true)
{
throw new BadRequestException("Invalid content.");
}
if (Request.ContentLength > 105906176 && !_globalSettings.SelfHosted) // 101 MB, give em' 1 extra MB for cushion
{
throw new BadRequestException("Max file size for direct upload is 100 MB.");
}
var send = await _sendRepository.GetByIdAsync(new Guid(id));
await Request.GetSendFileAsync(async (stream) =>
{
await _sendService.UploadFileToExistingSendAsync(stream, send);
});
}
[AllowAnonymous]
[HttpPost("file/validate/azure")]
public async Task<OkObjectResult> AzureValidateFile()
{
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
{
{
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
{
return;
}
await _sendService.ValidateSendFile(send);
}
catch (Exception e)
{
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonConvert.SerializeObject(eventGridEvent)}");
return;
}
}
}
});
}
[HttpPut("{id}")]
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
{

View File

@ -1,5 +1,10 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.EventGrid;
using Microsoft.Azure.EventGrid.Models;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
@ -29,5 +34,47 @@ namespace Bit.Api.Utilities
return obj;
}
/// <summary>
/// Validates Azure event subscription and calls the appropriate event handler. Responds HttpOk.
/// </summary>
/// <param name="request">HttpRequest received from Azure</param>
/// <param name="eventTypeHandlers">Dictionary of eventType strings and their associated handlers.</param>
/// <returns>OkObjectResult</returns>
/// <remarks>Reference https://docs.microsoft.com/en-us/azure/event-grid/receive-events</remarks>
public async static Task<OkObjectResult> HandleAzureEvents(HttpRequest request,
Dictionary<string, Func<EventGridEvent, Task>> eventTypeHandlers)
{
var response = string.Empty;
var requestContent = await new StreamReader(request.Body).ReadToEndAsync();
if (string.IsNullOrWhiteSpace(requestContent))
{
return new OkObjectResult(response);
}
var eventGridSubscriber = new EventGridSubscriber();
var eventGridEvents = eventGridSubscriber.DeserializeEventGridEvents(requestContent);
foreach (var eventGridEvent in eventGridEvents)
{
if (eventGridEvent.Data is SubscriptionValidationEventData eventData)
{
// Might want to enable additional validation: subject, topic etc.
var responseData = new SubscriptionValidationResponse()
{
ValidationResponse = eventData.ValidationCode
};
return new OkObjectResult(responseData);
}
else if (eventTypeHandlers.ContainsKey(eventGridEvent.EventType))
{
await eventTypeHandlers[eventGridEvent.EventType](eventGridEvent);
}
}
return new OkObjectResult(response);
}
}
}

View File

@ -108,6 +108,27 @@ namespace Bit.Api.Utilities
}
}
public static async Task GetSendFileAsync(this HttpRequest request, Func<Stream, Task> callback)
{
var boundary = GetBoundary(MediaTypeHeaderValue.Parse(request.ContentType),
_defaultFormOptions.MultipartBoundaryLengthLimit);
var reader = new MultipartReader(boundary, request.Body);
var dataSection = await reader.ReadNextSectionAsync();
if (dataSection != null)
{
if (ContentDispositionHeaderValue.TryParse(dataSection.ContentDisposition, out var dataContent)
&& HasFileContentDisposition(dataContent))
{
using (dataSection.Body)
{
await callback(dataSection.Body);
}
}
dataSection = null;
}
}
private static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
{

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Enums
{
public enum FileUploadType
{
Direct = 0,
Azure = 1,
}
}

View File

@ -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; }

View File

@ -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; }
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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));
}
}
}

View File

@ -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;
}
}
}

View File

@ -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?)));
}
}
}