Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/Api/Vault/Controllers/CiphersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1498,10 +1498,53 @@ public async Task<AttachmentResponseModel> GetAttachmentData(Guid id, string att
{
var userId = _userService.GetProperUserId(User).Value;
var cipher = await GetByIdAsync(id, userId);
if (cipher == null)
{
throw new NotFoundException();
}

var result = await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
return new AttachmentResponseModel(result);
}

/// <summary>
/// Serves a locally stored attachment file using a time-limited, signed token.
/// This endpoint replaces direct static file access for self-hosted environments
/// to ensure that only authorized users can download attachment files.
/// </summary>
[AllowAnonymous]
[HttpGet("attachment/download")]
public async Task<IActionResult> DownloadAttachmentAsync([FromQuery] string token)
{
if (string.IsNullOrEmpty(token))
{
throw new NotFoundException();
}

(Guid cipherId, string attachmentId) = CipherService.ParseAttachmentDownloadToken(token,
_cipherService.CreateAttachmentDownloadProtector());

var cipher = await _cipherRepository.GetByIdAsync(cipherId);
if (cipher == null)
{
throw new NotFoundException();
}

var attachments = cipher.GetAttachments();
if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachmentData))
{
throw new NotFoundException();
}

var stream = await _attachmentStorageService.GetAttachmentReadStreamAsync(cipher, attachmentData);
if (stream == null)
{
throw new NotFoundException();
}

return File(stream, "application/octet-stream", attachmentData.FileName);
}

[HttpPost("{id}/attachment/{attachmentId}/share")]
[RequestSizeLimit(Constants.FileSize101mb)]
[DisableFormValueModelBinding]
Expand Down
5 changes: 5 additions & 0 deletions src/Core/Services/IAttachmentStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,10 @@ public interface IAttachmentStorageService
Task DeleteAttachmentsForUserAsync(Guid userId);
Task<string> GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData);
Task<string> GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData);
/// <summary>
/// Opens a read stream for a locally stored attachment file.
/// Returns null if the storage implementation does not support direct streaming (e.g. cloud storage).
/// </summary>
Task<Stream?> GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData);
Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway);
}
2 changes: 2 additions & 0 deletions src/Core/Vault/Services/ICipherService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Microsoft.AspNetCore.DataProtection;

namespace Bit.Core.Vault.Services;

Expand Down Expand Up @@ -36,6 +37,7 @@ Task<IEnumerable<CipherDetails>> ShareManyAsync(IEnumerable<(CipherDetails ciphe
Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false);
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId);
Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);
ITimeLimitedDataProtector CreateAttachmentDownloadProtector();
Task<bool> ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData);
Task ValidateBulkCollectionAssignmentAsync(IEnumerable<Guid> collectionIds, IEnumerable<Guid> cipherIds, Guid userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ public async Task DeleteAttachmentsForUserAsync(Guid userId)
await InitAsync(_defaultContainerName);
}

public Task<Stream> GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData)
{
// Azure storage uses SAS URLs for downloads; direct streaming is not supported.
return Task.FromResult<Stream>(null);
}

public async Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway)
{
await InitAsync(attachmentData.ContainerName);
Expand Down
55 changes: 53 additions & 2 deletions src/Core/Vault/Services/Implementations/CipherService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.DataProtection;

namespace Bit.Core.Vault.Services;

Expand Down Expand Up @@ -48,6 +49,10 @@ public class CipherService : ICipherService
private readonly IApplicationCacheService _applicationCacheService;
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IDataProtectionProvider _dataProtectionProvider;

internal static readonly string AttachmentDownloadProtectorPurpose = "AttachmentDownload";
private static readonly TimeSpan _downloadLinkLifetime = TimeSpan.FromMinutes(1);

public CipherService(
ICipherRepository cipherRepository,
Expand All @@ -68,7 +73,8 @@ public CipherService(
IPolicyRequirementQuery policyRequirementQuery,
IApplicationCacheService applicationCacheService,
IFeatureService featureService,
IPricingClient pricingClient)
IPricingClient pricingClient,
IDataProtectionProvider dataProtectionProvider)
{
_cipherRepository = cipherRepository;
_folderRepository = folderRepository;
Expand All @@ -89,6 +95,7 @@ public CipherService(
_applicationCacheService = applicationCacheService;
_featureService = featureService;
_pricingClient = pricingClient;
_dataProtectionProvider = dataProtectionProvider;
}

public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate,
Expand Down Expand Up @@ -412,17 +419,61 @@ public async Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher
throw new NotFoundException();
}

var url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data);

// For local (self-hosted) storage, generate a time-limited signed download URL
// to prevent unauthenticated access to predictable attachment file paths.
// Cloud storage (Azure) already uses time-limited SAS URLs.
if (_attachmentStorageService.FileUploadType == FileUploadType.Direct)
{
var protector = _dataProtectionProvider.CreateProtector(AttachmentDownloadProtectorPurpose);
var timedProtector = protector.ToTimeLimitedDataProtector();
var token = timedProtector.Protect(
$"{cipher.Id}|{attachmentId}",
_downloadLinkLifetime);
url = $"{_globalSettings.BaseServiceUri.Api}/ciphers/attachment/download?token={Uri.EscapeDataString(token)}";
}

var response = new AttachmentResponseData
{
Cipher = cipher,
Data = data,
Id = attachmentId,
Url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data),
Url = url,
};

return response;
}

public ITimeLimitedDataProtector CreateAttachmentDownloadProtector()
{
return _dataProtectionProvider
.CreateProtector(AttachmentDownloadProtectorPurpose)
.ToTimeLimitedDataProtector();
}

public static (Guid cipherId, string attachmentId) ParseAttachmentDownloadToken(
string token, ITimeLimitedDataProtector protector)
{
string payload;
try
{
payload = protector.Unprotect(token);
}
catch
{
throw new NotFoundException();
}

var parts = payload.Split('|');
if (parts.Length != 2 || !Guid.TryParse(parts[0], out var cipherId))
{
throw new NotFoundException();
}

return (cipherId, parts[1]);
}

public async Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false)
{
if (!orgAdmin && !await UserCanDeleteAsync(cipherDetails, deletingUserId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ private string AttachmentFilePath(string attachmentId, Guid cipherId, Guid? orga
organizationId.HasValue ?
AttachmentFilePath(OrganizationDirectoryPath(cipherId, organizationId.Value, temp), attachmentId) :
AttachmentFilePath(CipherDirectoryPath(cipherId, temp), attachmentId);
public Task<Stream?> GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData)
{
var path = AttachmentFilePath(attachmentData.AttachmentId, cipher.Id, temp: false);
if (!File.Exists(path))
{
return Task.FromResult<Stream?>(null);
}

return Task.FromResult<Stream?>(File.OpenRead(path));
}

public Task<string> GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData)
=> Task.FromResult($"{cipher.Id}/attachment/{attachmentData.AttachmentId}");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public Task<string> GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachmen
return Task.FromResult((string)null);
}

public Task<Stream> GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData)
{
return Task.FromResult<Stream>(null);
}

public Task<string> GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData)
{
return Task.FromResult(default(string));
Expand Down
111 changes: 111 additions & 0 deletions test/Api.Test/Vault/Controllers/CiphersControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Bit.Core.Vault.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.DataProtection;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
Expand Down Expand Up @@ -2152,4 +2153,114 @@ public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFi
Assert.Equal(newFolderId, result.FolderId);
Assert.True(result.Favorite);
}

[Theory, BitAutoData]
public async Task GetAttachmentData_CipherNotFound_ThrowsNotFoundException(
Guid cipherId, string attachmentId, Guid userId,
SutProvider<CiphersController> sutProvider)
{
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs((Guid?)userId);
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(cipherId, userId).ReturnsNull();

await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.GetAttachmentData(cipherId, attachmentId));
}

[Theory, BitAutoData]
public async Task GetAttachmentData_CipherFound_ReturnsAttachmentResponse(
Guid cipherId, string attachmentId, Guid userId,
SutProvider<CiphersController> sutProvider)
{
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs((Guid?)userId);

var cipherDetails = new CipherDetails { Id = cipherId, UserId = userId, Type = CipherType.Login, Data = "{}" };
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(cipherId, userId)
.Returns(Task.FromResult(cipherDetails));

var responseData = new AttachmentResponseData
{
Id = attachmentId,
Url = "https://example.com/download",
Data = new CipherAttachment.MetaData { FileName = "test.txt" },
Cipher = cipherDetails,
};
sutProvider.GetDependency<ICipherService>()
.GetAttachmentDownloadDataAsync(cipherDetails, attachmentId)
.Returns(Task.FromResult(responseData));

var result = await sutProvider.Sut.GetAttachmentData(cipherId, attachmentId);

Assert.NotNull(result);
Assert.Equal(attachmentId, result.Id);
}

[Theory, BitAutoData]
public async Task DownloadAttachmentAsync_EmptyToken_ThrowsNotFoundException(
SutProvider<CiphersController> sutProvider)
{
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DownloadAttachmentAsync(string.Empty));
}

[Theory, BitAutoData]
public async Task DownloadAttachmentAsync_InvalidToken_ThrowsNotFoundException(
SutProvider<CiphersController> sutProvider)
{
// Use a real ephemeral data protector - any invalid token will fail to unprotect
var dataProtectionProvider = new EphemeralDataProtectionProvider();
var protector = dataProtectionProvider
.CreateProtector("AttachmentDownload")
.ToTimeLimitedDataProtector();

sutProvider.GetDependency<ICipherService>()
.CreateAttachmentDownloadProtector()
.Returns(protector);

await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DownloadAttachmentAsync("invalid-token"));
}

[Theory, BitAutoData]
public async Task DownloadAttachmentAsync_ValidToken_CipherNotFound_ThrowsNotFoundException(
Guid cipherId, string attachmentId,
SutProvider<CiphersController> sutProvider)
{
// Create a real token using ephemeral data protection
var dataProtectionProvider = new EphemeralDataProtectionProvider();
var protector = dataProtectionProvider
.CreateProtector("AttachmentDownload")
.ToTimeLimitedDataProtector();
var token = protector.Protect($"{cipherId}|{attachmentId}", TimeSpan.FromMinutes(1));

sutProvider.GetDependency<ICipherService>()
.CreateAttachmentDownloadProtector()
.Returns(protector);

sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(cipherId).ReturnsNull();

await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DownloadAttachmentAsync(token));
}

[Theory, BitAutoData]
public async Task DownloadAttachmentAsync_ValidToken_NoAttachments_ThrowsNotFoundException(
Guid cipherId, string attachmentId,
SutProvider<CiphersController> sutProvider)
{
var dataProtectionProvider = new EphemeralDataProtectionProvider();
var protector = dataProtectionProvider
.CreateProtector("AttachmentDownload")
.ToTimeLimitedDataProtector();
var token = protector.Protect($"{cipherId}|{attachmentId}", TimeSpan.FromMinutes(1));

sutProvider.GetDependency<ICipherService>()
.CreateAttachmentDownloadProtector()
.Returns(protector);

var cipher = new Cipher { Id = cipherId, Attachments = null };
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(cipherId).Returns(cipher);

await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DownloadAttachmentAsync(token));
}
}
Loading
Loading