CORS与CSRF防护实战:Web安全的常见陷阱与最佳实践

深入讲解CORS跨域资源共享的原理与配置,详解CSRF跨站请求伪造的防护方案,提供Nginx、Spring Boot、Go的实战配置,分析常见安全陷阱与调试技巧。

引言

CORS和CSRF是Web开发中最常见的安全问题,也是最容易配置错误的地方。错误的CORS配置会导致安全漏洞,而CSRF防护不当则可能被恶意利用。本文将深入讲解这两个安全机制的原理与实战方案。

CORS(跨域资源共享)

什么是CORS

同源策略(Same-Origin Policy):
┌─────────────────────────────────────────┐
│ 协议 + 域名 + 端口 必须完全相同         │
│                                         │
│ ✅ 同源:                               │
│    https://api.example.com:443          │
│    https://api.example.com:443          │
│                                         │
│ ❌ 跨域:                               │
│    https://api.example.com              │
│    https://web.example.com              │
│                                         │
│    https://api.example.com              │
│    http://api.example.com               │
│                                         │
│    https://api.example.com:8080         │
│    https://api.example.com:8081         │
└─────────────────────────────────────────┘

CORS作用:
允许服务器声明哪些源可以访问其资源,打破同源策略限制

CORS请求类型

简单请求(Simple Request):
┌─────────────────────────────────────────┐
│ 条件:                                  │
│ 1. 方法:GET、POST、HEAD               │
│ 2. Header仅限:                         │
│    - Accept                             │
│    - Accept-Language                    │
│    - Content-Language                   │
│    - Content-Type(仅限三种)           │
│                                         │
│ 流程:                                  │
│ 浏览器 ────── 请求 ──────▶ 服务器       │
│ 浏览器 ◀──── 响应 ────── 服务器         │
│         (包含CORS头)                    │
└─────────────────────────────────────────┘

预检请求(Preflight Request):
┌─────────────────────────────────────────┐
│ 条件:不满足简单请求条件                 │
│                                         │
│ 流程:                                  │
│ 1. 浏览器发送OPTIONS预检请求            │
│    ┌──────────────────────────────┐    │
│    │ OPTIONS /api/data HTTP/1.1   │    │
│    │ Origin: https://web.com      │    │
│    │ Access-Control-Request-      │    │
│    │   Method: PUT                │    │
│    │ Access-Control-Request-      │    │
│    │   Headers: X-Custom-Header   │    │
│    └──────────────────────────────┘    │
│                                         │
│ 2. 服务器返回允许的源、方法、Header     │
│    ┌──────────────────────────────┐    │
│    │ HTTP/1.1 204 No Content      │    │
│    │ Access-Control-Allow-Origin: │    │
│    │   https://web.com            │    │
│    │ Access-Control-Allow-Method: │    │
│    │   PUT, POST                  │    │
│    │ Access-Control-Allow-        │    │
│    │   Headers: X-Custom-Header   │    │
│    │ Access-Control-Max-Age: 86400│    │
│    └──────────────────────────────┘    │
│                                         │
│ 3. 浏览器发送实际请求                   │
└─────────────────────────────────────────┘

CORS响应头详解

服务器响应头:
┌─────────────────────────────────────────┐
│ Access-Control-Allow-Origin             │
│   值:* 或 具体域名                     │
│   作用:允许访问的源                    │
│   注意:* 不能与 credentials 同时使用   │
│                                         │
│ Access-Control-Allow-Methods            │
│   值:GET, POST, PUT, DELETE...         │
│   作用:允许的HTTP方法                  │
│                                         │
│ Access-Control-Allow-Headers            │
│   值:Content-Type, Authorization...    │
│   作用:允许的请求头                    │
│                                         │
│ Access-Control-Allow-Credentials        │
│   值:true                              │
│   作用:允许携带Cookie                  │
│   注意:与 * 互斥                       │
│                                         │
│ Access-Control-Expose-Headers           │
│   值:X-Custom-Header...                │
│   作用:允许前端访问的响应头            │
│                                         │
│ Access-Control-Max-Age                  │
│   值:秒数(如86400)                   │
│   作用:预检请求缓存时间                │
└─────────────────────────────────────────┘

Nginx CORS配置

基础配置

# nginx.conf
server {
    listen 80;
    server_name api.example.com;
    
    # CORS配置
    location /api/ {
        # 允许的源(生产环境不要用 *)
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://web.example.com';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With';
            add_header 'Access-Control-Max-Age' 86400;
            add_header 'Content-Length' 0;
            add_header 'Content-Type' 'text/plain';
            return 204;
        }
        
        add_header 'Access-Control-Allow-Origin' 'https://web.example.com';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With';
        
        # 代理到后端
        proxy_pass http://backend;
    }
}

动态多源配置

# 支持多个允许的源
map $http_origin $cors_origin {
    default "";
    "https://web.example.com" "https://web.example.com";
    "https://admin.example.com" "https://admin.example.com";
    "https://mobile.example.com" "https://mobile.example.com";
    "~^https://.*\.example\.com$" "$http_origin";  # 正则匹配子域名
}

server {
    listen 80;
    server_name api.example.com;
    
    location /api/ {
        # 预检请求
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $cors_origin;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With, X-Custom-Header';
            add_header 'Access-Control-Max-Age' 86400;
            return 204;
        }
        
        # 实际请求
        if ($cors_origin != "") {
            add_header 'Access-Control-Allow-Origin' $cors_origin;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With, X-Custom-Header';
        }
        
        proxy_pass http://backend;
    }
}

携带Cookie的配置

server {
    listen 80;
    server_name api.example.com;
    
    location /api/ {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://web.example.com';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';  # 允许Cookie
            add_header 'Access-Control-Max-Age' 86400;
            return 204;
        }
        
        add_header 'Access-Control-Allow-Origin' 'https://web.example.com';
        add_header 'Access-Control-Allow-Credentials' 'true';  # 允许Cookie
        
        proxy_pass http://backend;
    }
}

Spring Boot CORS配置

全局配置

@Configuration
@EnableWebMvc
public class CorsConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(
                "https://web.example.com",
                "https://admin.example.com"
            )
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders(
                "Content-Type",
                "Authorization",
                "X-Requested-With"
            )
            .exposedHeaders("X-Custom-Header")
            .allowCredentials(true)
            .maxAge(86400);  // 预检请求缓存24小时
    }
}

基于配置的动态源

@Configuration
public class DynamicCorsConfig {
    
    @Value("${cors.allowed-origins}")
    private List<String> allowedOrigins;
    
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(allowedOrigins);
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList(
            "Content-Type",
            "Authorization",
            "X-Requested-With"
        ));
        config.setAllowCredentials(true);
        config.setMaxAge(86400L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        
        return new CorsFilter(source);
    }
}
# application.yml
cors:
  allowed-origins:
    - https://web.example.com
    - https://admin.example.com
    - https://mobile.example.com

控制器级别配置

@RestController
@RequestMapping("/api/users")
@CrossOrigin(
    origins = "https://web.example.com",
    methods = {RequestMethod.GET, RequestMethod.POST},
    allowedHeaders = {"Content-Type", "Authorization"},
    maxAge = 3600
)
public class UserController {
    
    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.getUser(id);
    }
}

Go CORS中间件

基础实现

package middleware

import (
    "net/http"
    "strings"
)

type CORSConfig struct {
    AllowedOrigins   []string
    AllowedMethods   []string
    AllowedHeaders   []string
    ExposedHeaders   []string
    AllowCredentials bool
    MaxAge           int
}

var DefaultCORSConfig = CORSConfig{
    AllowedOrigins:   []string{"https://web.example.com"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowedHeaders:   []string{"Content-Type", "Authorization", "X-Requested-With"},
    ExposedHeaders:   []string{},
    AllowCredentials: true,
    MaxAge:           86400,
}

func CORSMiddleware(config CORSConfig) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")
            
            // 检查Origin是否在允许列表中
            allowed := false
            for _, allowedOrigin := range config.AllowedOrigins {
                if allowedOrigin == origin || allowedOrigin == "*" {
                    allowed = true
                    break
                }
            }
            
            if !allowed {
                next.ServeHTTP(w, r)
                return
            }
            
            // 设置CORS响应头
            w.Header().Set("Access-Control-Allow-Origin", origin)
            w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
            w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
            
            if len(config.ExposedHeaders) > 0 {
                w.Header().Set("Access-Control-Expose-Headers", strings.Join(config.ExposedHeaders, ", "))
            }
            
            if config.AllowCredentials {
                w.Header().Set("Access-Control-Allow-Credentials", "true")
            }
            
            if config.MaxAge > 0 {
                w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
            }
            
            // 预检请求
            if r.Method == "OPTIONS" {
                w.WriteHeader(http.StatusNoContent)
                return
            }
            
            next.ServeHTTP(w, r)
        })
    }
}

// 使用示例
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    
    handler := CORSMiddleware(DefaultCORSConfig)(mux)
    http.ListenAndServe(":8080", handler)
}

CSRF(跨站请求伪造)

CSRF攻击原理

CSRF攻击流程:
┌─────────────────────────────────────────┐
│ 1. 用户登录银行网站                       │
│    https://bank.com/login                │
│    Cookie: session=abc123                │
│                                         │
│ 2. 用户访问恶意网站                       │
│    https://evil.com                      │
│    页面包含:                            │
│    <img src="https://bank.com/transfer   │
│         ?to=attacker&amount=1000">      │
│                                         │
│ 3. 浏览器自动发送请求                     │
│    GET https://bank.com/transfer         │
│        ?to=attacker&amount=1000          │
│    Cookie: session=abc123  ◀─ 自动携带   │
│                                         │
│ 4. 银行服务器验证Cookie有效               │
│    执行转账操作                          │
│                                         │
│ 结果:用户损失1000元                      │
└─────────────────────────────────────────┘

CSRF防护方案

防护方案:
┌─────────────────────────────────────────┐
│ 1. CSRF Token(推荐)                    │
│    └─ 服务器生成随机Token                │
│    └─ 表单中隐藏字段或Header传递         │
│    └─ 服务器验证Token有效性               │
│                                         │
│ 2. SameSite Cookie属性                  │
│    └─ Strict:完全禁止跨站发送           │
│    └─ Lax:允许部分GET跨站               │
│    └─ None:允许跨站(需Secure)         │
│                                         │
│ 3. 验证Origin/Referer                   │
│    └─ 检查请求来源是否合法               │
│                                         │
│ 4. 双重Cookie验证                       │
│    └─ Token存入Cookie和表单              │
│    └─ 服务器对比两者是否一致             │
│                                         │
│ 5. 关键操作二次验证                      │
│    └─ 转账、修改密码等需要短信验证       │
└─────────────────────────────────────────┘

CSRF Token实现

Spring Boot CSRF配置

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .ignoringRequestMatchers("/api/public/**")  // 忽略公开接口
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );
        
        return http.build();
    }
}
// 前端获取CSRF Token
// 从Cookie中读取
function getCsrfToken() {
    const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
    return match ? match[1] : '';
}

// 发送请求时携带Token
fetch('/api/transfer', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-XSRF-TOKEN': getCsrfToken()  // 添加到Header
    },
    body: JSON.stringify({
        to: 'user123',
        amount: 100
    })
});

Go CSRF中间件

package csrf

import (
    "crypto/rand"
    "encoding/base64"
    "net/http"
    "time"
)

type CSRFMiddleware struct {
    cookieName string
    headerName string
    secure     bool
}

func NewCSRFMiddleware() *CSRFMiddleware {
    return &CSRFMiddleware{
        cookieName: "csrf_token",
        headerName: "X-CSRF-Token",
        secure:     true,
    }
}

func (m *CSRFMiddleware) Handler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 安全方法不需要验证
        if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
            // 生成Token并设置Cookie
            token := m.generateToken()
            http.SetCookie(w, &http.Cookie{
                Name:     m.cookieName,
                Value:    token,
                Path:     "/",
                HttpOnly: false,  // 前端需要读取
                Secure:   m.secure,
                SameSite: http.SameSiteStrictMode,
            })
            
            // 将Token存入上下文(供模板使用)
            ctx := context.WithValue(r.Context(), "csrf_token", token)
            next.ServeHTTP(w, r.WithContext(ctx))
            return
        }
        
        // 验证Token
        cookieToken, err := r.Cookie(m.cookieName)
        if err != nil {
            http.Error(w, "CSRF token missing", http.StatusForbidden)
            return
        }
        
        headerToken := r.Header.Get(m.headerName)
        if headerToken == "" {
            headerToken = r.FormValue("_csrf")  // 表单字段
        }
        
        if cookieToken.Value != headerToken {
            http.Error(w, "CSRF token invalid", http.StatusForbidden)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

func (m *CSRFMiddleware) generateToken() string {
    bytes := make([]byte, 32)
    rand.Read(bytes)
    return base64.URLEncoding.EncodeToString(bytes)
}

// 使用示例
func main() {
    mux := http.NewServeMux()
    
    // 表单页面
    mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
        token := r.Context().Value("csrf_token").(string)
        html := `
            <form method="POST" action="/api/transfer">
                <input type="hidden" name="_csrf" value="` + token + `">
                <input type="text" name="to" placeholder="Recipient">
                <input type="number" name="amount" placeholder="Amount">
                <button type="submit">Transfer</button>
            </form>
        `
        w.Write([]byte(html))
    })
    
    // API接口
    mux.HandleFunc("/api/transfer", func(w http.ResponseWriter, r *http.Request) {
        // 处理转账逻辑
        w.Write([]byte("Transfer successful"))
    })
    
    csrfMiddleware := NewCSRFMiddleware()
    handler := csrfMiddleware.Handler(mux)
    
    http.ListenAndServe(":8080", handler)
}

SameSite Cookie配置

// 设置SameSite属性
http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    "abc123",
    Path:     "/",
    HttpOnly: true,
    Secure:   true,  // 仅HTTPS
    SameSite: http.SameSiteLaxMode,  // Lax模式
})

/*
SameSite模式对比:
┌─────────────────────────────────────────┐
│ Strict:                                │
│   - 完全禁止跨站发送Cookie              │
│   - 从其他网站点击链接也不会携带        │
│   - 最安全,但用户体验差                │
│                                         │
│ Lax(默认):                           │
│   - 允许顶级导航GET请求携带             │
│   - 禁止POST、iframe、AJAX跨站          │
│   - 平衡安全与体验                      │
│                                         │
│ None:                                  │
│   - 允许所有跨站请求携带                │
│   - 必须配合Secure属性                  │
│   - 最不安全,仅用于特殊场景            │
└─────────────────────────────────────────┘
*/

常见陷阱与调试

CORS常见错误

// 错误1:预检请求失败
/*
Access to fetch at 'https://api.example.com/data' 
from origin 'https://web.example.com' 
has been blocked by CORS policy: 
Response to preflight request doesn't pass access control check: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.
*/

// 原因:服务器没有正确处理OPTIONS请求
// 解决:确保OPTIONS请求返回正确的CORS头

// 错误2:携带Cookie时Origin不能为 *
/*
Access to fetch at 'https://api.example.com/data' 
from origin 'https://web.example.com' 
has been blocked by CORS policy: 
The value of the 'Access-Control-Allow-Origin' header in the response 
must not be the wildcard '*' when the request's credentials mode is 'include'.
*/

// 原因:allowCredentials=true时,Origin不能是 *
// 解决:指定具体的Origin

// 错误3:自定义Header未在允许列表中
/*
Access to fetch at 'https://api.example.com/data' 
from origin 'https://web.example.com' 
has been blocked by CORS policy: 
Request header field X-Custom-Header is not allowed by 
Access-Control-Allow-Headers in preflight response.
*/

// 原因:自定义Header需要在Access-Control-Allow-Headers中声明
// 解决:将X-Custom-Header加入允许列表

CORS调试工具

# 使用curl测试CORS
curl -I -X OPTIONS \
  -H "Origin: https://web.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  https://api.example.com/data

# 预期响应:
# HTTP/1.1 204 No Content
# Access-Control-Allow-Origin: https://web.example.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type
# Access-Control-Max-Age: 86400

总结

CORS配置最佳实践

场景配置建议
生产环境明确指定允许的Origin列表,不用 *
需要CookieAllowCredentials=true,Origin必须具体
预检缓存Max-Age设置为86400(24小时)
自定义Header在Allow-Headers中明确声明

CSRF防护最佳实践

防护方式适用场景安全性
CSRF Token表单提交、API请求⭐⭐⭐⭐⭐
SameSite=Lax现代浏览器⭐⭐⭐⭐
验证Origin辅助验证⭐⭐⭐
双重Cookie简单场景⭐⭐⭐
二次验证敏感操作⭐⭐⭐⭐⭐

关键原则

  1. CORS不是安全机制:只是浏览器的限制,服务器仍需验证权限
  2. CSRF Token必须随机:使用加密安全的随机数生成器
  3. SameSite配合Secure:SameSite=None必须设置Secure
  4. 分层防护:多种防护方式组合使用
  5. 定期审计:检查CORS配置是否过于宽松

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页