The Wayback Machine - https://web.archive.org/web/20220211081413/https://github.com/dotnet/aspnetcore/issues/38111
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Blazor Server auth improvements #38111

Open
javiercn opened this issue Nov 5, 2021 · 7 comments
Open

Blazor Server auth improvements #38111

javiercn opened this issue Nov 5, 2021 · 7 comments

Comments

@javiercn
Copy link
Contributor

@javiercn javiercn commented Nov 5, 2021

Looking at the feedback in these area there are several recurring pain points affecting users.

It has come up several times that there is no good mechanism to update the expiration of the cookie when using cookie authentication (our default implementation choice uses ASP.NET Identity). This is something we might want to consider improving by some sort of pinging mechanism to one of Identity's urls (like Account/Manage) via a fetch request using JS interop.

The other major challenge here is with regards to how AuthenticationService interoperates with other parts of the user app, like scoped services in their own "sub-scope". This is relevant when users are leveraging things like Entity Framework for which we recommend using owning component scope as well as other libraries like HttpClientFactory that create their own scopes. In these cases, users are not able to access the current authenticated user within those services or any information associated with the circuit. The challenge here involves "being able" to have a "per circuit" scope that flows to any "nested scope".

The other piece of feedback we've received in this area involves how to communicate information to a given circuit from outside of the context of the circuit. This involves passing information during "circuit" startup and while the circuit is running. We might be able to create some documentation to specifically define how these pattern should work or streamline it in our default implementation.

@msftbot
Copy link
Contributor

@msftbot msftbot bot commented Nov 11, 2021

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

@Sebazzz
Copy link

@Sebazzz Sebazzz commented Feb 10, 2022

I'd like to add my own perspective on this: there is no good framework provided way to allow SPA Blazor-server applications to work with Cookie Authentication.

Cookie refresh

When Blazor Server works in the most ideal situation, you have a persistent websockets connection with the server. That means during your usage of the web application you might never be in the situation where you make a connection to an endpoint that requires authentication. You will probably request assets like static files, but those generally do not require authentication. Only endpoints that call AuthenticateAsync will have any sliding expiration cookie refreshed.

You can't say that you don't need any authentication cookie refreshed once you're using the web application because:

  1. When you accidently or intentionally refresh the page, it is undesirable to get logged out. Users do not like that.
  2. Some 3rd party components, like Kendo Blazor upload require a separate endpoint to handle the file upload. Obviously we need to have authentication on that endpoint - so a non-expired cookie.

Keep alive

To keep the cookie updated you need a lot of extra machinery, for instance in the form of a keep-alive mechanism. That means you'Il get something like this:

public static void MapKeepAlive(this IEndpointRouteBuilder endpointBuilder, string path) {
    endpointBuilder.MapGet(path, async (ctx) => {
            // Authentication keeps the cookie alive. KeepAlive is only used when the user is active in the application.
            AuthenticateResult result = await ctx.AuthenticateAsync();

            string? userName = result.Principal?.Identity?.Name;

            HttpResponse response = ctx.Response;
            response.ContentType = "text/plain";
            
            // Make sure keepalive url is not cached
            ResponseHeaders typedHeaders = response.GetTypedHeaders();
            typedHeaders.CacheControl = new() {NoCache = true, NoStore = true, MaxAge = TimeSpan.Zero, MustRevalidate = true};

            await response.WriteAsync($"OK - {userName ?? "(not authenticated)"}");
        }
    ).WithDisplayName("KeepAlive");
}

And of course, at the client side you need to call this keep-alive either through an iframe or asynchronous javascript call. At minimum you should call it at [cookie expiration time] divided by 2 so it will fall into the sliding cookie expiration window. However, you don't want a persistent keep alive but only when the user is actually doing something in the application (like navigating, clicking on something that doesn't necessary result in navigation, or typing).

However, this is quite difficult to get that stable so eventually we resorted to just make sure that the keepalive endpoint is called at least every [interval] when it is detected that the user is doing something.

Anti Forgery Tokens

In some cases it is necessary to make requests to non-Blazor endpoints (for instance the Kendo Upload I mentioned earlier). In that case you'd like those endpoints to be protected by CSRF tokens. There is no framework built-in (recommended) way that allows these anti forgery tokens to be made available.

Also when it comes to authentication, it is very awkward to need to have separate Razor forms (which separate layout pages, possibly Javascript libraries to augment the forms, not being able to share common form components, etc) to allow authentication in an otherwise SPA Blazor application. We eventually figured out a way, by using a form that posts to an endpoint and allow that endpoint to redirect back to the Blazor page.

In the _Host.cshtml:

@inject IAntiforgery Antiforgery

// ...

 @(await Html.RenderComponentAsync<App>(renderMode: RenderMode.Server, new { AntiforgeryInformation = Antiforgery.GetAndStoreTokens(HttpContext) }))

In App.razor:

<CascadingValue IsFixed="true" Value="AntiforgeryInformation">
    @* The Router, AuthorizedRouteView, CascadingAuthenticationState, etc here *@
</CascadingValue>

@code {
    [Parameter]
    public AntiforgeryTokenSet AntiforgeryInformation { get; set; } = null!;
}

On the login form component:

<CascadingValue IsFixed="true" Value="EditContext">
<form method="post" action="/toegangscontrole/inloggen" @ref="@_formRef">
    <AntiforgeryToken/>

    @*Form fields here *@

   <button class="button button__primary" id="login-button" type="button" @onclick="this.OnLogin" @ref="@_submitButtonRef">Inloggen</button>
</form>
</CascadingValue>

@code {
 private async Task OnLogin() {
        EditContext.Validate();

        if (EditContext.GetValidationMessages().Any() == false)
        {
            await JSRuntime.InvokeVoidAsync("app.submitForm", _formRef);
        }
    }
}

And with the AntiForgeryToken component being:

public sealed class AntiforgeryToken : ComponentBase {
    [CascadingParameter]
    public AntiforgeryTokenSet? Antiforgery { get; set; }

    /// <inheritdoc />
    protected override void BuildRenderTree(RenderTreeBuilder builder) {
        if (this.Antiforgery == null) throw new InvalidOperationException("Anti forgery information is not present - please use this component only within a context where anti forgery information is present");

        builder.OpenElement(0, "input");
        builder.AddAttribute(1, "type", "hidden");
        builder.AddAttribute(2, "name", this.Antiforgery.FormFieldName);
        builder.AddAttribute(3, "value", this.Antiforgery.RequestToken);
        builder.CloseElement();
    }

}

If you take a upload component you take the cascading anti forgery token and pass it into the HTTP request using headers.

This does work. and is protecting all endpoints from CSRF - but a framework provided way that is also supported by Microsoft would be much better.

Session hijacking / CSRF

Blazor allows a fallback to long polling using POST requests to the Blazor endpoint. However, this also opens
the possibility to session hijacking an authenticated session. If one existing circuit is considered to be authenticated,
and you can guess the session ID, you can access that circuit without the passing any authentication token.

You can argue that the session ID is random enough that it cannot be guessed, but some infosec consultant do not like that you can get into an authenticated session by just guessing a session ID without any need to authenticate like through a cookie. Likewise, there isn't any CSRF token necessary either.

If you do manage to break in, you can send abritrary commands in the POST payload just like this and manipulating the running circuit:

image

Final words

Not intented to be negative or anything: There are some things that need improvement on the framework level when it comes to authentication and security.

However, we Blazor and like it very much! It is a refreshing framework in the world of overengineered overly complex Javascript frameworks and the build systems that come with it. It has made us more productive on a scale I would never have expected.

I just hope the ASP.NET Core devs see this and understand the necessity of some improvements in regard to the points above.

Related issues: #34095 and #36030 (via @marwalsch) , #39932 (via @javiercn)

@javiercn
Copy link
Contributor Author

@javiercn javiercn commented Feb 10, 2022

#39932 is meant to capture this in more detail

@javiercn
Copy link
Contributor Author

@javiercn javiercn commented Feb 10, 2022

This does work. and is protecting all endpoints from CSRF - but a framework provided way that is also supported by Microsoft would be much better.

Our recommended way of handling things like file uploads is via the Stream APIs that we added to JS interop, which leverage the current authentication context and don't require additional measures like CSRF protection.

Blazor allows a fallback to long polling using POST requests to the Blazor endpoint. However, this also opens
the possibility to session hijacking an authenticated session. If one existing circuit is considered to be authenticated,
and you can guess the session ID, you can access that circuit without the passing any authentication token.

We don't recommend people using long polling with Blazor Server for the experience, and even if you do, the Circuit ID is a data protected identifier generated from 32 bytes of entropy using cryptographic APIs, so its as guessable as the key you use in your symmetric encryption, which means even when you use long polling you are perfectly safe.

@Sebazzz
Copy link

@Sebazzz Sebazzz commented Feb 10, 2022

This does work. and is protecting all endpoints from CSRF - but a framework provided way that is also supported by Microsoft would be much better.

Our recommended way of handling things like file uploads is via the Stream APIs that we added to JS interop, which leverage the current authentication context and don't require additional measures like CSRF protection.

I understand, as I mentioned: I can't control 3rd party components or other use cases where you might want to do requests outside of an active circuit.

Blazor allows a fallback to long polling using POST requests to the Blazor endpoint. However, this also opens
the possibility to session hijacking an authenticated session. If one existing circuit is considered to be authenticated,
and you can guess the session ID, you can access that circuit without the passing any authentication token.

We don't recommend people using long polling with Blazor Server for the experience, and even if you do, the Circuit ID is a data protected identifier generated from 32 bytes of entropy using cryptographic APIs, so its as guessable as the key you use in your symmetric encryption, which means even when you use long polling you are perfectly safe.

Don't recommend, perhaps, but the fallback is enabled by default.

I agree that the chance is very small that anything can be guessed, but it still can be seen as circumvention of authentication controls.

To clarify: In Javascript, authorization and CSRF cookies are generally not accessible because we place them with the HttpOnly flag. The circuit session ID however is accessible with Javascript, and is thus exploitable. If something would happen that caused this session ID to leak to an external server (via a form post, perhaps), then it is possible for that external server to do requests using that session ID. No cookies or CSRF token necessary.

@javiercn
Copy link
Contributor Author

@javiercn javiercn commented Feb 10, 2022

I agree that the chance is very small that anything can be guessed, but it still can be seen as circumvention of authentication controls.

It's not a very small chance, its an infinitesimal chance that can't be practically accomplished in a reasonable amount of time. You effectively have a similar chance of guessing the HTTP only cookie that you have of guessing the Circuit ID

To clarify: In Javascript, authorization and CSRF cookies are generally not accessible because we place them with thr HttpOnly flag. The circuit session ID however is accessible with Javascript, and is thus exploitable. If something would happen that caused this session ID to leak to an external server (via a form post, perhaps), then it is possible for that external server to do requests using that session ID. No cookies or CSRF token necessary.

The moment you have hostile code running in your app, your are already the victim of an XSS. There's no need for an attacker to try and guess or leak the circuit ID elsewhere as they can already operate from that context.

@Sebazzz
Copy link

@Sebazzz Sebazzz commented Feb 10, 2022

The moment you have hostile code running in your app, your are already the victim of an XSS. There's no need for an attacker to try and guess or leak the circuit ID elsewhere as they can already operate from that context.

I don't agree on that. Security is provided by having security on multiple layers. Preventing XSS exploits is one of them, but if that fails we prevent leaking cookies by setting a HttpOnly flag, if that fails we have short-lived cookies that are refreshed in 10 minutes, if that fails we might have another layer of protection (for instance a Web Application Firewall).

We run our web applications with minimal privileges so they can't infect the web server in case of an exploit. We use encrypted connection strings, Azure Key Vault or integrated security so that in the case of an exploit, we don't have any password leakage. We have a firewall so that in the case of password leakage, an attacker still does not have access to the database. Security builds on layers - it is not a single supposedly airtight hatch.

And that comes back to this:

its an infinitesimal chance that can't be practically accomplished in a reasonable amount of time

The session ID is leakable, so you might be in a situation where it is leaked (and not guessed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
3 participants