Enable same-origin credentials by default. Add E2E test to show they can be sent to different-origin domains too.

This commit is contained in:
Steve Sanderson 2018-04-09 11:10:28 +01:00
parent e096d7e0b1
commit 281d5a8751
10 changed files with 193 additions and 10 deletions

View File

@ -15,7 +15,7 @@ async function sendAsync(id: number, method: string, requestUri: string, body: s
let response: Response;
let responseText: string;
const requestInit = fetchArgs || {};
const requestInit: RequestInit = fetchArgs || {};
requestInit.method = method;
requestInit.body = body || undefined;

View File

@ -17,6 +17,13 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
/// </summary>
public class BrowserHttpMessageHandler : HttpMessageHandler
{
/// <summary>
/// Gets or sets the default value of the 'credentials' option on outbound HTTP requests.
/// Defaults to <see cref="FetchCredentialsOption.SameOrigin"/>.
/// </summary>
public static FetchCredentialsOption DefaultCredentials { get; set; }
= FetchCredentialsOption.SameOrigin;
static object _idLock = new object();
static int _nextRequestId = 0;
static IDictionary<int, TaskCompletionSource<HttpResponseMessage>> _pendingRequests
@ -47,7 +54,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
request.RequestUri,
request.Content == null ? null : await GetContentAsString(request.Content),
SerializeHeadersAsJson(request),
fetchArgs);
fetchArgs ?? CreateDefaultFetchArgs());
return await tcs.Task;
}
@ -93,6 +100,26 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
}
}
private static object CreateDefaultFetchArgs()
=> new { credentials = GetDefaultCredentialsString() };
private static object GetDefaultCredentialsString()
{
// See https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials for
// standard values and meanings
switch (DefaultCredentials)
{
case FetchCredentialsOption.Omit:
return "omit";
case FetchCredentialsOption.SameOrigin:
return "same-origin";
case FetchCredentialsOption.Include:
return "include";
default:
throw new ArgumentException($"Unknown credentials option '{DefaultCredentials}'.");
}
}
// Keep in sync with TypeScript class in Http.ts
private class ResponseDescriptor
{

View File

@ -0,0 +1,28 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Blazor.Browser.Http
{
/// <summary>
/// Specifies a value for the 'credentials' option on outbound HTTP requests.
/// </summary>
public enum FetchCredentialsOption
{
/// <summary>
/// Advises the browser never to send credentials (such as cookies or HTTP auth headers).
/// </summary>
Omit,
/// <summary>
/// Advises the browser to send credentials (such as cookies or HTTP auth headers)
/// only if the target URL is on the same origin as the calling application.
/// </summary>
SameOrigin,
/// <summary>
/// Advises the browser to send credentials (such as cookies or HTTP auth headers)
/// even for cross-origin requests.
/// </summary>
Include,
}
}

View File

@ -15,6 +15,8 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure
public BrowserFixture()
{
var opts = new ChromeOptions();
// Comment this out if you want to watch or interact with the browser (e.g., for debugging)
opts.AddArgument("--headless");
// On Windows/Linux, we don't need to set opts.BinaryLocation

View File

@ -106,6 +106,38 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
Assert.EndsWith("/test-referrer", _responseBody.Text);
}
[Fact]
public void CanSendAndReceiveCookies()
{
var app = MountTestComponent<CookieCounterComponent>();
var deleteButton = app.FindElement(By.Id("delete"));
var incrementButton = app.FindElement(By.Id("increment"));
app.FindElement(By.TagName("input")).SendKeys(_apiServerFixture.RootUri.ToString());
// Ensure we're starting from a clean state
deleteButton.Click();
Assert.Equal("Reset completed", WaitAndGetResponseText());
// Observe that subsequent requests manage to preserve state via cookie
incrementButton.Click();
Assert.Equal("Counter value is 1", WaitAndGetResponseText());
incrementButton.Click();
Assert.Equal("Counter value is 2", WaitAndGetResponseText());
// Verify that attempting to delete a cookie actually works
deleteButton.Click();
Assert.Equal("Reset completed", WaitAndGetResponseText());
incrementButton.Click();
Assert.Equal("Counter value is 1", WaitAndGetResponseText());
string WaitAndGetResponseText()
{
new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until(
driver => driver.FindElement(By.Id("response-text")) != null);
return app.FindElement(By.Id("response-text")).Text;
}
}
private void IssueRequest(string requestMethod, string relativeUri, string requestBody = null)
{
var targetUri = new Uri(_apiServerFixture.RootUri, relativeUri);

View File

@ -0,0 +1,38 @@
@inject System.Net.Http.HttpClient Http
<h1>Cookie counter</h1>
<p>The server increments the count by one on each request.</p>
<p>TestServer base URL: <input @bind(testServerBaseUrl) /></p>
<button id="delete" @onclick(DeleteCookie)>Delete cookie</button>
<button id="increment" @onclick(GetAndIncrementCounter)>Get and increment current value</button>
@if (!requestInProgress)
{
<p id="response-text">@responseText</p>
}
@functions
{
bool requestInProgress = false;
string testServerBaseUrl;
string responseText;
async void DeleteCookie()
{
await DoRequest("api/cookie/reset");
StateHasChanged();
}
async void GetAndIncrementCounter()
{
await DoRequest("api/cookie/increment");
StateHasChanged();
}
async Task DoRequest(string url)
{
requestInProgress = true;
responseText = await Http.GetStringAsync(testServerBaseUrl + url);
requestInProgress = false;
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Blazor.Browser.Http;
using Microsoft.AspNetCore.Blazor.Browser.Interop;
using Microsoft.AspNetCore.Blazor.Browser.Rendering;
using Microsoft.AspNetCore.Blazor.Components;
@ -12,6 +13,10 @@ namespace BasicTestApp
{
static void Main(string[] args)
{
// Needed because the test server runs on a different port than the client app,
// and we want to test sending/receiving cookies undering this config
BrowserHttpMessageHandler.DefaultCredentials = FetchCredentialsOption.Include;
// Signal to tests that we're ready
RegisteredFunction.Invoke<object>("testReady");
}

View File

@ -22,6 +22,7 @@
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
<option value="BasicTestApp.HttpClientTest.CookieCounterComponent">HttpClient cookies</option>
<option value="BasicTestApp.BindCasesComponent">@bind cases</option>
<option value="BasicTestApp.ExternalContentPackage">External content package</option>
<option value="BasicTestApp.SvgComponent">SVG</option>

View File

@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace TestServer.Controllers
{
[Route("api/[controller]/[action]")]
[EnableCors("AllowAll")] // Only because the test client apps runs on a different origin
public class CookieController : Controller
{
const string cookieKey = "test-counter-cookie";
public string Reset()
{
Response.Cookies.Delete(cookieKey);
return "Reset completed";
}
public string Increment()
{
var counter = 0;
if (Request.Cookies.TryGetValue(cookieKey, out var incomingValue))
{
counter = int.Parse(incomingValue);
}
counter++;
Response.Cookies.Append(cookieKey, counter.ToString());
return $"Counter value is {counter}";
}
}
}

View File

@ -20,14 +20,7 @@ namespace TestServer
services.AddMvc();
services.AddCors(options =>
{
options.AddPolicy("AllowAll", builder =>
{
builder
.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("MyCustomHeader");
});
options.AddPolicy("AllowAll", _ => { /* Controlled below */ });
});
}
@ -39,7 +32,32 @@ namespace TestServer
app.UseDeveloperExceptionPage();
}
AllowCorsForAnyLocalhostPort(app);
app.UseMvc();
}
private static void AllowCorsForAnyLocalhostPort(IApplicationBuilder app)
{
// It's not enough just to return "Access-Control-Allow-Origin: *", because
// browsers don't allow wildcards in conjunction with credentials. So we must
// specify explicitly which origin we want to allow.
app.Use((context, next) =>
{
if (context.Request.Headers.TryGetValue("origin", out var incomingOriginValue))
{
var origin = incomingOriginValue.ToArray()[0];
if (origin.StartsWith("http://localhost:") || origin.StartsWith("http://127.0.0.1:"))
{
context.Response.Headers.Add("Access-Control-Allow-Origin", origin);
context.Response.Headers.Add("Access-Control-Allow-Credentials", "true");
context.Response.Headers.Add("Access-Control-Allow-Methods", "HEAD,GET,PUT,POST,DELETE,OPTIONS");
context.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type,TestHeader,another-header");
context.Response.Headers.Add("Access-Control-Expose-Headers", "MyCustomHeader,TestHeader,another-header");
}
}
return next();
});
}
}
}