Search code examples
blazor-server-sidemiddlewarestatic-files

Can't Secure Hosted Blazor Webassembly File Download


I have a hosted blazor web assembly app where I want to secure file downloads so that only a user with the correct role / permissions can download a file from the folder. I do not want an anonymous user to be able to download any files from this folder. The files are located in a folder named Uploads in the server / api project, so they are not in the wwwroot folder.

The server pipeline looks like:

...
app.UseAuthorization();

app.UseBlazorFrameworkFiles();
app.UseStaticFiles();

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.ContentRootPath, "Uploads")),
    RequestPath = "/SellerFiles"
});

//app.Map("", appBuilder =>
//{
//    appBuilder.UseFilter();

//    appBuilder.UseFileServer(new FileServerOptions
//    {
//        FileProvider = new PhysicalFileProvider($@"{Path.Combine(builder.Environment.ContentRootPath, "Uploads")}"),
//        RequestPath = new PathString("/SellerFiles"), //empty, because root path is in Map now
//        EnableDirectoryBrowsing = false
//    }); 
//});

app.MapControllers();
...

UseFilters looks like

public class FilterMiddleware
{
    private readonly RequestDelegate _next;

    public FilterMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        List<Claim> claims = httpContext.User.Claims.ToList();

        await _next(httpContext);
        //if (true)
        //{
        //    //proceed serving files
        //    await _next(httpContext);
        //}
        //else
        //{
        //    //return you custom response
        //    await httpContext.Response.WriteAsync("Forbidden");
        //}
    }
}

public static class FilterMiddlewareExtensions
{
    public static IApplicationBuilder UseFilter(this IApplicationBuilder applicationBuilder)
    {
        return applicationBuilder.UseMiddleware<FilterMiddleware>();
    }
}

When a user requests a file from https://mywebsite.com/SellerFiles/..., I want to serve the file if they are authenticated, but not if they are anonymous.

I tried implementing the commented code app.Map UseFileServer, but I wasn't able to configure it correctly. I played around with it alot and I was not able to get it to hit a breakpoint in public async Task InvokeAsync when I made a request to /SellerFiles.

Right now, an authenticated user can download a file from the Uploads folder by making a call to /SellerFiles, but so can an anonymous user.

How can I properly protect the files?

Edit I think I found the problem, but I am not quite there yet. I followed the guidance here: Microsoft Link to Static File Authorization

I set the authorization fallback policy, and it works well. I had to make some controller methods AllowAnonymous. So now the Uploads folder should require authorization, but I can still download files when not logged in.

builder.Services.AddAuthorizationCore(options =>
{    
    var type = typeof(Permissions);

    foreach (var permission in type.GetFields())
    {
        options.AddPolicy(
            permission.GetValue(null)?.ToString() ?? "",
            policyBuilder => policyBuilder.RequireAssertion(
                context => context.User.HasClaim(claim => claim.Type == "Permissions" && claim.Value == permission.GetValue(null)?.ToString())));
    }

    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();

});
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
    app.UseSwagger();
    app.UseSwaggerUI();
}
else
{
    app.UseResponseCompression();
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseCors("AllowAll");

app.UseAuthentication();

app.UseSerilogRequestLogging();

app.UseStaticFiles();

app.UseAuthorization();

app.UseBlazorFrameworkFiles();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.MapHangfireDashboard();
app.MapHub<SignalR>("/chathub");


using (var scope = app.Services.CreateScope())
{   
    var jobService = scope.ServiceProvider.GetRequiredService<IScheduleJobs>();
    jobService.Run();
    //do your stuff....
}

var options = new DashboardOptions
{
    Authorization = new IDashboardAuthorizationFilter[]
        {
            new HangfireDashboardJwtAuthorizationFilter(tokenValidationParameters, "Administrator")
        }
};
app.UseHangfireDashboard("/hangfire", options);

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(
           Path.Combine(builder.Environment.ContentRootPath, "Uploads")),
    RequestPath = "/SellerFiles"
});

app.Run();

What am I missing?


Solution

  • Well, I solved my issue and wanted to show the solution. I ended up calling an API endpoint and streaming the file back to the client. Then I use a simple Javascript function to create an A tag and invoke the download. The user chooses where to save the file. Also, I removed all of the modifications I had made to Program.cs.

    This is my razor.cs code which is invoked by the user clicking the download button.

    protected async Task DownloadFile(CartViewModel cartViewModel)
    {
        FileDownload fileDownload = new FileDownload()
        {
            OrderId = cartViewModel.OrderId,
            SellerId = cartViewModel.Product?.SellerId,
            ZipFileName = cartViewModel.Product?.ZipFileName
        };
    
        if(OrderService != null)
        {
            bool response = await OrderService.DownloadFileStream(fileDownload, $"{ApiEndpoints.File}/download/{cartViewModel.Product?.SellerId}/{cartViewModel.OrderId}", JSruntime);
    
            if (response)
            {
                SnackbarService?.Add("File Downloaded Successfully", Severity.Success);
            }
            else
            {
                SnackbarService?.Add("Problem Downloading File", Severity.Error);
            }
        }            
    }
    

    This is my OrderService.DownloadFileStream method.

    public async Task<bool> DownloadFileStream(FileDownload model, string endPoint, IJSRuntime JSruntime)
    {
        try
        {
            var user = System.Text.Json.JsonSerializer.Serialize(model);
            var requestContent = new StringContent(user, Encoding.UTF8, "application/json");
    
            if (!await GetBearerToken())
            {
                return false;
            }
    
            Stream stream = await client.GetStreamAsync(endPoint);
    
    
            byte[] bytes;
            using (var memoryStream = new MemoryStream())
            {
                stream.CopyTo(memoryStream);
                bytes = memoryStream.ToArray();
            }
            string base64 = Convert.ToBase64String(bytes);
            await JSruntime.InvokeAsync<object>("saveAsFile", model.ZipFileName, base64);
    
    
        }
        catch (ApiException exception)
        {
            return false;
        }
    
        return true;
    }
    

    This is my controller endpoint.

    [HttpGet]
    [Authorize(Policy = Permissions.PurchaseProducts)]
    [Route("download/{SellerId}/{OrderId:int}")]
    public async Task<MemoryStream> Get([FromRoute] string SellerId, [FromRoute] int OrderId)
    {
        string filePath = string.Empty;
    
        DirectoryInfo d = new DirectoryInfo(@$"Uploads\{SellerId}\{OrderId}\");            
    
        FileInfo[] files = d.GetFiles();
    
        // There should only be 1 file in this folder
        foreach (FileInfo fileInfo in files)
        {
            if (fileInfo.Extension.ToLower().Trim() == ".zip")
            {
                filePath = @$"Uploads\{SellerId}\{OrderId}\{fileInfo.Name}";
            }
        }
    
        if(filePath == string.Empty)
        {
            return new MemoryStream();
        }
        
        //converting Pdf file into bytes array
        var dataBytes = await System.IO.File.ReadAllBytesAsync(filePath);
        //adding bytes to memory stream
        var dataStream = new MemoryStream(dataBytes);
    
        return dataStream;
    }
    

    And this is the Javascript function.

    function saveAsFile(filename, bytesBase64) {
        var link = document.createElement('a');
        link.download = filename;
        link.href = "data:application/octet-stream;base64," + bytesBase64;
        document.body.appendChild(link); // Needed for Firefox
        link.click();
        document.body.removeChild(link);
    }
    

    Since the files are in an Uploads folder outside of wwwroot, users cannot get to https://mywebsite.com/Uploads or any subfolders, so my files are protected. I plan to add checks in the controller endpoint to ensure that the user really is authorized to download the file, i.e., they paid for it.