MERN栈项目iOS设备POST请求CSRF验证失败403问题求助
我有一个MERN栈项目,当前前端部署在Vercel、后端部署在Heroku。项目在Windows和Android设备上运行完全正常,但在iPhone、Mac等iOS设备中,执行注册、登录等POST请求时,会出现CSRF validation failed ERRBADCSRF token 403错误。
此前项目在Vercel作为单项目部署时iOS设备可正常运行,但因Vercel对部分后端功能有限制,必须拆分前后端独立部署。已尝试调整sameSite配置、CORS规则、请求头设置、启用credentials、使用X-CSRF-Token作为请求头,以及添加后端域名到CORS源等方案,问题仍未解决。
相关代码
后端Index文件的CORS/CSRF配置
const env = process.env.NODE_ENV || 'development'; dotenv.config({ path: '.env' }); const app = express(); connectDB(); const db = admin.firestore(); app.use(bodyParser.json({ limit: '100mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '100mb' })); app.use(cookieParser()); console.log('Environment:', process.env.NODE_ENV); app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:3000', credentials: true, methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', allowedHeaders: 'Content-Type, Authorization, X-CSRF-Token, CSRF-TOKEN', })); console.log('CORS settings applied'); // Session middleware app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, store: MongoStore.create({ mongoUrl: process.env.MONGO_URI }), cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: process.env.NODE_ENV === 'production' ? 'None' : 'Lax' }, })); const csrfProtection = csurf({ cookie: { httpOnly: true, secure: true, // Set to true in production sameSite: process.env.NODE_ENV === 'production' ? 'None' : 'Lax', }, }); app.use(csrfProtection); // Middleware to set CSRF token in a cookie for all requests app.use((req, res, next) => { const csrfToken = req.csrfToken(); console.log('Generated CSRF Token:', csrfToken); // Log generated CSRF token res.cookie('CSRF-TOKEN', csrfToken, { httpOnly: false, // Allow the client to access the cookie secure: process.env.NODE_ENV === 'production', sameSite: 'None', }); next(); }); app.get('/api/csrf-token', (req, res) => { const csrfToken = req.csrfToken(); console.log('Returning CSRF Token:', csrfToken); // Log token being sent to frontend res.json({ csrfToken }); }); app.use((err, req, res, next) => { if (err.code === 'EBADCSRFTOKEN') { console.error('CSRF validation failed:', err); res.status(403).json({ error: 'Session has expired or form tampered with' }); } else { next(err); } }); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 1000, message: 'Too many requests from this IP, please try again later', }); app.use('/api/', limiter); app.use(helmet()); // Applies various security headers // Initialize Passport middleware app.use(passport.initialize()); app.use(passport.session()); app.use(express.json());
前端api.js代码
// src/utils/api.js import axios from 'axios'; import { BASE_URL } from '../Constants'; import { fetchCsrfToken } from './csrf'; const api = axios.create({ baseURL: BASE_URL, withCredentials: true, }); api.interceptors.request.use( async (config) => { try { const csrfToken = await fetchCsrfToken(); console.log('Setting CSRF Token in request header:', csrfToken); config.headers['CSRF-Token'] = csrfToken; } catch (error) { console.error('Error fetching CSRF token:', error); } return config; }, (error) => { console.error('Request setup error:', error); return Promise.reject(error); } ); api.interceptors.response.use( (response) => response, (error) => { if (error.response) { console.error('Response error:', error.response.status, error.response.data); } return Promise.reject(error); } ); export default api;
前端csrf.js代码
// utils/csrf.js import { BASE_URL } from "../Constants"; export const fetchCsrfToken = async () => { try { const response = await fetch(`${BASE_URL}/csrf-token`, { credentials: 'include', }); if (!response.ok) { throw new Error('Failed to fetch CSRF token'); } const data = await response.json(); console.log('Fetched CSRF Token on frontend:', data.csrfToken); // Log CSRF token return data.csrfToken; } catch (error) { console.error('Error fetching CSRF token:', error); throw error; } };
组件handleSubmit请求代码
const handleSubmit = async (e) => { e.preventDefault(); // Trim all input fields to remove leading/trailing spaces setFirstName(firstName.trim()); setLastName(lastName.trim()); setEmail(email.trim()); setPhone(phone.trim()); if (validateForm()) { setLoading(true); try { const formData = new FormData(); formData.append('firstName', firstName.trim()); formData.append('lastName', lastName.trim()); formData.append('email', email.trim()); formData.append('phone', phone.trim()); formData.append('password', password); formData.append('cv', cv); formData.append('timezone', timezone); const response = await api.post('/instructors/register', formData); console.log('Registration successful:', response); navigate('/mentor/authentication'); } catch (error) { console.error('Registration error:', error); setGeneralError('An error occurred. Please try again.'); } finally { setLoading(false); } } };
解决方案
1. 统一CSRF头与Cookie命名,适配Safari大小写敏感问题
Safari对HTTP头和Cookie名称的大小写更敏感,建议统一使用X-CSRF-Token作为头名和Cookie名,避免大小写不一致导致Token匹配失败。
后端修改:
// 修改CSRF Cookie设置 res.cookie('X-CSRF-Token', csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === 'production', sameSite: 'None', maxAge: 3600000, // 添加过期时间,避免Cookie被过早清理 }); // 修改CORS允许的头 app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:3000', credentials: true, methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', allowedHeaders: 'Content-Type, Authorization, X-CSRF-Token', // 只保留统一的头名 exposedHeaders: 'X-CSRF-Token', // 允许前端读取Cookie之外的响应头(可选) }));
前端修改:
在api.js中设置统一的请求头:
config.headers['X-CSRF-Token'] = csrfToken;
2. 优化CSRF Token获取逻辑,直接从Cookie读取
避免每次请求都发起额外的/api/csrf-token请求,而是直接从Cookie中提取Token,减少跨域请求的不确定性,同时避免Token不匹配:
前端修改csrf.js:
// utils/csrf.js export const fetchCsrfToken = () => { // 从Cookie中提取X-CSRF-Token const match = document.cookie.match(/X-CSRF-Token=([^;]+)/); if (match) { const token = match[1]; console.log('Extracted CSRF Token from Cookie:', token); return Promise.resolve(token); } // 如果Cookie中没有,再发起请求 fallback return fetch(`${BASE_URL}/api/csrf-token`, { credentials: 'include', }).then(response => { if (!response.ok) throw new Error('Failed to fetch CSRF token'); return response.json(); }).then(data => { console.log('Fetched CSRF Token from API:', data.csrfToken); return data.csrfToken; }).catch(error => { console.error('Error fetching CSRF token:', error); throw error; }); };
3. 强化Session与CSRF Cookie的跨域适配
Safari对跨域Cookie的限制非常严格,必须确保Secure、SameSite=None和Domain配置正确:
后端Session配置修改:
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, store: MongoStore.create({ mongoUrl: process.env.MONGO_URI }), cookie: { secure: true, // 生产环境强制设为true,Safari要求跨域Cookie必须是HTTPS httpOnly: true, sameSite: 'None', maxAge: 24 * 60 * 60 * 1000, // 设置合理的过期时间 domain: process.env.COOKIE_DOMAIN, // 比如Heroku域名:'.herokuapp.com',确保子域也能读取(可选,根据实际域名调整) }, })); // 修改CSRF保护的Cookie配置 const csrfProtection = csurf({ cookie: { httpOnly: true, secure: true, sameSite: 'None', maxAge: 3600000, }, });
4. 处理FormData请求的CSRF验证
当使用FormData发送请求时,csurf默认可能无法正确解析请求头,需要手动配置value函数来从请求头或FormData中提取Token:
后端修改CSRF保护配置:
const csrfProtection = csurf({ cookie: { httpOnly: true, secure: true, sameSite: 'None', maxAge: 3600000, }, // 自定义Token提取逻辑,支持从请求头或FormData中获取 value: (req) => { // 优先从请求头取 const headerToken = req.headers['x-csrf-token']; if (headerToken) return headerToken; // 如果是FormData请求,从body中取 if (req.body && req.body._csrf) return req.body._csrf; // 最后从查询参数取(可选) return req.query._csrf; } });
如果使用FormData,可以在提交时额外添加Token字段:
// 组件handleSubmit中 formData.append('_csrf', csrfToken);
5. 调整Helmet配置,避免干扰跨域Cookie
Helmet的某些默认安全头可能会影响跨域Cookie的传递,比如Cross-Origin-Embedder-Policy或Cross-Origin-Opener-Policy,可以调整这些配置:
app.use(helmet({ crossOriginEmbedderPolicy: false, // 禁用该策略,避免影响跨域资源 contentSecurityPolicy: { directives: { defaultSrc: ["'self'", process.env.FRONTEND_URL], // 根据实际需求添加其他规则 }, }, }));
6. 确认Heroku的SSL配置
确保Heroku后端使用HTTPS访问,因为Safari不允许非HTTPS环境下的跨域Secure Cookie。可以检查Heroku的SSL设置,确保已启用自动SSL证书。
内容的提问来源于stack exchange,提问作者Dawood Hamayun

