CORS
API はポート 5080、フロントエンドページはポート 5178 で動作しています。ブラウザーは既定ではページから API のレスポンスを読み取らせません。この章ではクロスオリジンリソース共有(Cross-Origin Resource Sharing、CORS)を設定し、ローカルフロントエンドから API を呼び出して結果を読み取れるようにします。
前章のコードに CORS ポリシーを追加します。関連ファイル全体は次のとおりです。
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanWriteTodos", policy => policy.RequireAuthenticatedUser().RequireRole("editor"));
});
builder.Services.AddCors(options => options.AddPolicy("LocalFrontend", policy =>
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.WithExposedHeaders("Location")));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRouting();
app.UseCors("LocalFrontend");
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Categories.AnyAsync())
{
db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
await db.SaveChangesAsync();
}
}
app.MapGet("/me", (ClaimsPrincipal user) => new { Name = user.Identity?.Name }).RequireAuthorization();
var todos = app.MapGroup("/todos").WithTags("Todos").RequireAuthorization();
todos.MapGet("/", async (TodoDbContext db) =>
await db.Todos.AsNoTracking().OrderBy(t => t.Id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());
todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is null ? TypedResults.NotFound()
: TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});
todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}",
new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400).RequireAuthorization("CanWriteTodos");
todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
todo.Title = input.Title;
todo.Done = input.Done;
todo.CategoryId = input.CategoryId;
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).ProducesProblem(400).RequireAuthorization("CanWriteTodos");
todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).RequireAuthorization("CanWriteTodos");
app.Run();using System.ComponentModel.DataAnnotations;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Todo> Todos { get; set; } = [];
}
public record CreateTodo(
[Required, StringLength(100)] string Title,
[Range(1, int.MaxValue)] int CategoryId);
public record ReplaceTodo(
[Required, StringLength(100)] string Title,
bool Done,
[Range(1, int.MaxValue)] int CategoryId);
public record TodoResponse(int Id, string Title, bool Done, int CategoryId);using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}許可するフロントエンドの URL は構成から取得します。
{
"ConnectionStrings": {
"Todos": "Data Source=todos-20.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"Cors": {
"Origins": [
"http://localhost:5178"
]
}
}まずオリジンを確認する
オリジン(origin)は、プロトコル、ホスト、ポートの組み合わせで決まります。
| URL | http://localhost:5080 と同一オリジンか |
|---|---|
http://localhost:5080/todos | はい。パスはオリジンに影響しない |
http://localhost:5178 | いいえ。ポートが異なる |
http://127.0.0.1:5080 | いいえ。ホストが異なる |
https://localhost:5080 | いいえ。プロトコルが異なる |
ブラウザーの同一オリジンポリシー(same-origin policy)は、スクリプトが別オリジンのレスポンスを読み取ることを制限します。CORS を設定すると、サーバーは指定したオリジンのページにレスポンスの読み取りを許可できます。
API を起動する
前章のサービスを停止し、リポジトリのルートから実行します。
cd samples/20-cors
dotnet user-jwts create --name alice --role editor --valid-for 1h --output token
dotnet runツールが出力した完全なトークンをコピーします。トークンは本章のプロジェクトで生成してください。データベースは新しい todos-20.db を使うので、Todo 一覧は空で、カテゴリは Work(1)と Life(2)です。
実際のブラウザーページで確認する
サンプルには Node.js でローカルに配信する HTML ページが含まれており、npm パッケージは不要です。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Todo CORS Demo</title>
</head>
<body>
<h1>Todo CORS Demo</h1>
<p>Page origin: localhost:5178; API origin: localhost:5080.</p>
<label for="token">Local test token (kept only in this page memory)</label>
<input id="token" type="password" autocomplete="off" spellcheck="false" size="48">
<button id="read" type="button">Read Todos</button>
<button id="create" type="button">Create Todo</button>
<pre id="output" role="status" aria-live="polite">Paste the editor token generated in this chapter.</pre>
<script type="module">
const token = document.querySelector('#token');
const output = document.querySelector('#output');
async function request(method) {
if (!token.value.trim()) {
output.textContent = 'Please enter a local test token.';
return;
}
const headers = { Authorization: `Bearer ${token.value.trim()}` };
const options = { method, headers, credentials: 'omit' };
if (method === 'POST') {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify({ title: 'Browser todo', categoryId: 1 });
}
try {
const response = await fetch('http://localhost:5080/todos', options);
const body = await response.text();
output.textContent = `HTTP ${response.status}\nLocation: ${response.headers.get('Location') ?? '(none)'}\n${body}`;
} catch {
output.textContent = 'Request failed: Check that the API is running and inspect the Network panel for errors.';
}
}
document.querySelector('#read').addEventListener('click', () => request('GET'));
document.querySelector('#create').addEventListener('click', () => request('POST'));
</script>
</body>
</html>import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const page = await readFile(new URL('./index.html', import.meta.url));
createServer((request, response) => {
if (request.url !== '/') {
response.writeHead(404).end();
return;
}
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
}).end(page);
}).listen(5178, '127.0.0.1', () => {
console.log('Open http://localhost:5178/');
});別のターミナルでも samples/20-cors に移動して、次を実行します。
node browser/serve.mjs次の出力が表示されます。
Open http://localhost:5178/HTML をダブルクリックして file:// で開かず、この HTTP アドレスを使ってください。トークンをページの入力欄に貼り、「Read Todos」をクリックすると、初回は次のようになります。
HTTP 200
Location: (none)
[]続けて「Create Todo」をクリックします。
HTTP 201
Location: /todos/1
{"id":1,"title":"Browser todo","done":false,"categoryId":1}これは新しいデータベースを使った場合の ID です。もう一度クリックすると新しいタスクが作られます。トークンは現在のページのメモリ内だけにあり、localStorage、Cookie、サーバー側のファイルには保存されません。
プリフライト:実際のリクエストの前に許可を確認する
ブラウザーの開発者ツールで Network パネルを開くと、OPTIONS プリフライトリクエスト(preflight request)を確認できます。この例では Authorization を手動で送信し、POST は application/json も使うため、プリフライトが発生します。POST だけがプリフライト対象というわけではありません。ブラウザーが結果をキャッシュするため、クリックのたびに OPTIONS が現れるとは限りません。
curl でもプリフライトのレスポンスを確認できます。このコマンドには JWT を含めず、Todo も作成しません。
curl -i -X OPTIONS http://localhost:5080/todos -H "Origin: http://localhost:5178" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: authorization,content-type"主なレスポンスヘッダーは次のとおりです。
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5178
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Authorization,Content-Typeプリフライトには実際のリクエストの Bearer トークンが含まれないため、認証と認可より前に処理する必要があります。この例ではまず UseRouting() でエンドポイントを決め、次に UseCors() でプリフライトを処理します。実際の GET と POST には引き続き有効なトークンが必要で、書き込みには editor ロールも必要です。
ポリシーの 4 つの設定
| 設定 | 役割 |
|---|---|
WithOrigins(...) | http://localhost:5178 という正確なオリジンを許可する。パスや末尾のスラッシュは含めない |
WithMethods(...) | GET、POST、PUT、DELETE をフロントエンドに許可する |
WithHeaders(...) | 実際のリクエストで Authorization と Content-Type を送信できるようにする |
WithExposedHeaders("Location") | フロントエンドの JavaScript がレスポンスの Location を読み取れるようにする |
リクエストヘッダーとレスポンスヘッダーを分けて設定するのはなぜでしょうか。Authorization を送信できても、任意のレスポンスヘッダーを読み取れるとは限りません。Location はクロスオリジンのスクリプトに既定では公開されないため、追加指定が必要です。指定がなければ Network パネルでは見えても、response.headers.get('Location') は null を返す場合があります。
この例では Bearer ヘッダーを手動で送信し、credentials: 'omit' でブラウザーが Cookie を付加しないようにしているため、AllowCredentials() は不要です。Cookie ログインに切り替える場合は、資格情報を別途設定し、クロスサイトリクエストフォージェリ(cross-site request forgery、CSRF)への対策が必要です。ASP.NET Core CORS ドキュメント
許可されないオリジンに必ず 403 が返るわけではない
プリフライトコマンドの Origin を http://localhost:5179 に変えて実行してみます。この例では引き続き 204 が返りますが、Access-Control-Allow-Origin はありません。そのためブラウザーは後続のクロスオリジンリクエストを許可しません。
curl はブラウザーの同一オリジンポリシーを実行しません。有効なトークンを使って許可されない Origin を偽装し、curl で実際のリクエストを送ると、レスポンスにクロスオリジン許可ヘッダーがなくてもサーバーのハンドラーが実行される場合があります。プリフライトを必要としないブラウザーリクエストもサーバーまで届くことがありますが、スクリプトからレスポンスを読み取れません。
CORS は認証や認可の代わりにはなりません。この章で権限のない書き込みを防ぐのは JWT と CanWriteTodos です。CORS はブラウザーがページにレスポンスの読み取りを許すかどうかを決めます。CORS の仕組み
ヒント
「ブラウザーで CORS エラー」と表示されたら、まず Network パネルのプリフライトと実際のリクエストを確認します。API が起動しているか、Origin が完全一致するか、メソッドとリクエストヘッダーが許可されているか、実際のリクエストが 401 / 403 ではないかを調べてください。ブラウザーのエラーだけを見て業務権限を変更しないでください。
FastAPI との比較
FastAPI / Starlette の CORSMiddleware に対応し、許可するオリジン、メソッド、リクエストヘッダー、読み取り可能なレスポンスヘッダーをそれぞれ設定します。ブラウザーのプリフライトと同一オリジン規則はサーバーのフレームワークが変わっても同じです。
まとめ
- オリジンはプロトコル、ホスト、ポートで決まります。CORS は指定したクロスオリジンのレスポンスをブラウザースクリプトが読めるようにします。
- オリジン、メソッド、リクエストヘッダーを正確に設定します。Location などのレスポンスヘッダーを読むには、それらも明示的に公開します。
- プリフライトはクロスオリジンの許可を確認します。実際のリクエストには引き続き認証と認可が必要で、CORS ミドルウェアはその前に配置します。
- 許可されないオリジンにも HTTP レスポンスが返ることがありますが、許可ヘッダーはありません。curl の成功だけではブラウザーの CORS 設定が正しいとは証明できません。
- CORS はユーザー権限やデータ分離を提供せず、CSRF 対策の代わりにもなりません。
