跳至主要內容

CORS ​

API 在 5080 連接埠,前端頁面在 5178 連接埠,瀏覽器預設不允許頁面讀取這個 API 的回應。這一章設定跨源資源共享(Cross-Origin Resource Sharing,CORS),允許本地前端呼叫 API 並讀取結果。

在上一章程式碼中新增 CORS 策略,完整檔案如下:

20-cors/Program.cs
cs
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();
20-cors/Models.cs
cs
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);
20-cors/TodoDbContext.cs
cs
using Microsoft.EntityFrameworkCore;

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    public DbSet<Category> Categories => Set<Category>();
}

允許的前端地址來自設定:

20-cors/appsettings.json
json
{
  "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)由協議、主機和連接埠共同決定:

地址與 http://localhost:5080 是否同源
http://localhost:5080/todos是,路徑不同不影響源
http://localhost:5178否,連接埠不同
http://127.0.0.1:5080否,主機不同
https://localhost:5080否,協議不同

瀏覽器的同源策略(same-origin policy)限制指令碼讀取其他源的回應。設定 CORS 後,伺服器可以允許指定源的頁面讀取回應。

啟動 API ​

停止上一章服務,在儲存庫根目錄執行:

bash
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)。

用真正的瀏覽器頁面驗證 ​

範例附帶一個 HTML 頁面,用 Node.js 提供本地存取地址,不需要安裝 npm 包:

20-cors/browser/index.html
html
<!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>
20-cors/browser/serve.mjs
js
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,執行:

bash
node browser/serve.mjs

預期輸出:

text
Open http://localhost:5178/

必須透過這個 HTTP 地址開啟頁面,不要雙擊 HTML 以 file:// 開啟。把權杖貼上到頁面的輸入框,點選“Read Todos”,首次執行得到:

text
HTTP 200
Location: (none)
[]

再點選“Create Todo”,得到:

text
HTTP 201
Location: /todos/1
{"id":1,"title":"Browser todo","done":false,"categoryId":1}

編號以新資料庫為前提;再次點選會建立新任務。權杖只保留在目前頁面記憶體中,不寫入 localStorage、Cookie 或伺服器檔案。

預檢:先問能不能發,再發實際請求 ​

開啟瀏覽器開發者工具的 Network 面板,可以看到 OPTIONS 預檢請求(preflight request)。本例手動傳送 Authorization,POST 還使用 application/json,這些條件會觸發預檢;並非只有 POST 才會預檢。瀏覽器可能快取預檢結果,所以不一定每次點選都出現 OPTIONS。

用 curl 可以檢視預檢回應。下面的指令不帶 JWT,也不會建立 Todo:

bash
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
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 角色。

策略中的四項設定 ​

設定作用
WithOrigins(...)允許 http://localhost:5178 這個精確的源,不包含路徑或末尾斜槓
WithMethods(...)允許前端使用 GET、POST、PUT、DELETE
WithHeaders(...)允許實際請求攜帶 Authorization、Content-Type
WithExposedHeaders("Location")允許前端 JavaScript 讀取回應中的 Location

為什麼請求標頭和回應標頭要分開設定?允許傳送 Authorization,不代表可以讀取任意回應標頭。Location 不屬於預設向跨源指令碼暴露的回應標頭,因此需要額外宣告;否則網路面板裡可能看得到,但 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 不執行瀏覽器的同源策略。帶著有效權杖,用 curl 直接傳送實際請求,即使偽造一個不允許的 Origin,伺服器端處理常式仍可能執行,只是回應沒有跨源許可頭。某些不需要預檢的瀏覽器請求也可能發到伺服器端,只是指令碼讀不到回應。

CORS 不能替代身分驗證和授權。本章阻止無權限寫入的是 JWT 和 CanWriteTodos;CORS 決定瀏覽器是否允許頁面讀取回應。CORS 工作方式

提示

遇到“瀏覽器報 CORS 錯誤”,先檢查 Network 中的預檢和實際請求:API 是否啟動、Origin 是否完全一致、方法和請求標頭是否被允許,以及實際請求是不是 401/403。不要只看到瀏覽器報錯就修改業務權限。

FastAPI 對照

這對應 FastAPI / Starlette 的 CORSMiddleware:分別設定允許的源、方法、請求標頭和可讀取的回應標頭。瀏覽器的預檢與同源規則不會因為伺服器端框架不同而改變。

總結 ​

  • 源由協議、主機和連接埠決定;CORS 授權瀏覽器指令碼讀取指定的跨源回應。
  • 精確設定源、方法和請求標頭;讀取 Location 等回應標頭時還要顯式暴露它們。
  • 預檢檢查跨源許可,實際請求仍需身分驗證和授權;CORS 中介軟體放在身分驗證授權之前。
  • 不允許的源可能仍得到 HTTP 回應,但沒有許可頭;curl 成功不能證明瀏覽器 CORS 設定正確。
  • CORS 不提供使用者權限或資料隔離,也不能替代 CSRF 防護。

本章完成「安全」階段。下一章:測試,用自動化檢查保護這些行為。上一章:授權。

採用 .NET 10 與 Minimal API · 所有範例皆可直接 dotnet run