首页 / 资讯中心 / 文章详情

Python后端权限系统05:Cookie 会自己跟着请求走——Origin、CSRF 与浏览器凭据边界

Python后端权限系统05:Cookie 会自己跟着请求走——Origin、CSRF 与浏览器凭据边界 ★ FEATURED ARTICLE
Python后端权限系统05Cookie 会自己跟着请求走——Origin、CSRF 与浏览器凭据边界Alice 保持采购系统的登录状态同时打开了一篇外部网页。网页里藏着一个表单提交目标是采购系统的创建申请接口。即使外部网页读不到响应浏览器仍可能按照自己的 Cookie 规则把 Alice 的凭据附在请求上。服务器如果只看“有合法 Cookie”就可能以 Alice 的身份执行这次并非她想要的写入。这就是本课要分析的跨站请求伪造问题。它不是攻击者破解了随机令牌也不要求攻击者知道密码问题出在浏览器替用户自动携带了认证材料而服务器没有区分用户真正发起的应用操作与外部站点诱导的请求。我们会用实际 HTTP 请求构造反例同时准确说明 TestClient 与真正浏览器的证据边界。上一课 E04响应拒绝之外还要检查撤销效果lessons/05/answer_previous.py登录后旋转 refresh先证明新 access 能工作再重放旧 refresh。它不仅检查重放返回 401还立刻检查新 access 与新 refresh 都已失效最后证明另一次独立登录仍可用。运行python lessons/05/answer_previous.py即可验证这条完整因果链。本地实际运行得到E04 PASS: refresh replay revokes the whole family这比只写一个“旧 token 不能刷新”的测试更严格。旧 token 无效并不自动证明 family 的撤销已经提交如果事务在报告错误时回滚新 access 仍可能继续工作。本篇末尾的完整答案把成功前置条件、危险动作和危险动作后的状态都写出来适合迁移到其他撤销场景。Bearer 与 Cookie 的区别在于谁决定附带凭据脚本使用 Bearer 模式时需要显式构造 Authorization 头。普通外部 HTML 表单不能随意添加这个头因此不会仅仅因为用户曾登录就自动携带 Bearer token。浏览器 Cookie 模式不同当目标、域、路径、Secure 和 SameSite 等条件满足时浏览器会替请求附加 Cookie。因此 Bearer API 与 Cookie API 的防护不能照搬。Bearer token 被恶意脚本读取后仍然可以被盗用特别是把 token 长期放在可被脚本访问的存储中时但这主要是凭据泄露问题。Cookie 即使设置 HttpOnly、脚本读不到值也可能被浏览器自动带入攻击者诱导的请求这才是我们此处讨论的 CSRF 边界。ProcureSafe 提供两种登录入口用于比较。POST /auth/login返回 JSON tokenPOST /auth/browser/login除返回同样结构外还设置名为ps_access的 Cookie。教学代码保留 JSON 便于观察两种策略不能把它描述为已经完成生产级 BFF生产设计需要明确前端能接触哪些凭据、刷新凭据保存在哪里、退出如何清理浏览器状态等问题。Cookie 的三个属性分别减少什么风险HttpOnly 限制浏览器脚本读取 Cookie 值减少某些脚本注入直接窃取凭据的路径。但如果同源页面已经执行恶意 JavaScript它通常仍能发起同源请求让浏览器携带 Cookie。因此 HttpOnly 不会把 XSS 变成无害事件也不能代替正确处理用户内容与脚本来源。Secure 要求浏览器仅通过 HTTPS 发送该 Cookie。应用工厂默认开启它本地沙盒为了使用 TestClient 的 HTTP 地址会显式设置secure_cookiesFalse。这只是实验环境选择部署时不能顺手把它复制成默认值。若使用 HTTPS 的 TestClient base_url也可以保持 Secure 属性进行相应检查。SameSiteLax 限制部分跨站场景下携带 Cookie给 CSRF 增加一道防线但站点与源不是同一个概念浏览器还会根据导航方式、请求方法等条件做判断。不要简单声称“Lax 禁止所有跨站请求”也不要因为设置了它就删除服务端 CSRF 校验。我们把这些属性作为叠加的控制而非让一个属性承担所有风险。服务端为什么同时检查 Origin 与 CSRF tokenCookie 认证的写请求必须携带精确匹配的Origin: http://localhost:8000还要携带X-CSRF-Token。Origin 表达发起请求的网页来源包含协议、主机和端口。本地合同中的localhost与127.0.0.1不是可随意互换的同一个 Origin端口不同也不相同。部署环境应使用明确配置的可信来源集合而不是对任意输入做模糊包含判断。CSRF token 是与会话关联的随机值。服务端保存其摘要请求带来原值时计算摘要并比较。外部站点无法仅凭知道接口 URL 就猜出这个值也不应能读取登录或同源页面返回的秘密值。它证明请求方持有与当前会话配套的信息而不只是浏览器恰好自动附上了 Cookie。两个检查承担不同作用。Origin 直接阻止错误来源CSRF token 为会话增加专门的写请求证明。任何一项缺失或错误都不能因为另一项正确就放行。普通非浏览器客户端可以伪造 Origin所以不能把它当作登录身份CSRF 防护的威胁模型是限制攻击网页借浏览器行事非浏览器调用者仍需要有效认证凭据。这里有一个便于教学但需要记住的范围校验用于 Cookie 认证的写操作Bearer 调用不需要 CSRF token因为凭据并非浏览器自动从 Cookie 附加。请求同时带 Cookie 与 Authorization 时项目返回 400避免在两个可能代表不同身份的凭据之间静默挑选一个。所谓“优先使用更方便的那个”会制造审计与行为之间的歧义。登录本身也可能被跨站诱导很多教程只保护登录后的创建、修改和删除却把登录入口完全排除。攻击者可能诱导受害者登录到攻击者控制的账号之后受害者上传的内容或做出的操作会落入错误账户。这类登录 CSRF 不依赖受害者原有 Cookie而是试图改变受害者浏览器所代表的身份。本项目的浏览器登录入口也要求正确 Origin。实验先发送错误来源的登录请求确认 403再发送正确来源请求并接收 Cookie。不要因为登录前没有 CSRF token就推导出登录请求无需任何来源检查。后面 OIDC 回调还会把 state 与最初浏览器绑定解决另一种身份接续过程中的跨浏览器移交风险。浏览器登录响应中的 token 不应被整体写入控制台。为了检查 Cookie 属性可以读取响应的 Set-Cookie 头并只断言属性是否存在为了使用 CSRF只在内存中保留csrf_token。一份便于复制的调试日志不应该变成一份可直接冒用会话的操作手册。CORS 控制的重点是读取能力不是所有请求的发送能力常见误解是“我们没有允许跨域所以其他站点发不来请求”。浏览器对某些简单请求可以直接发送只是不把响应内容交给调用网页。一个改变服务器状态的表单请求即使攻击页面最终看不到响应也可能已经造成结果。因此 CORS 不是 CSRF 的替代品。自定义请求头经常触发预检预检可以增加攻击难度但应用仍需要在真正写请求中验证身份、来源与 CSRF token。否则某次配置变化、代理转发或新增兼容入口就可能绕过原先偶然成立的限制。安全规则应落在明确负责该规则的服务端路径上而不是寄托于前端运行环境碰巧阻止某个请求。同理CSRF 防护也不解决 XSS。如果攻击代码已经在可信同源环境运行它可能读取页面中的 CSRF token并执行用户权限内的操作。两类漏洞需要分别治理。把所有浏览器风险笼统写成“跨域问题”既会选错防护也会让故障排查失去方向。本课实验能证明什么不能证明什么TestClient 会保存服务端设置的 Cookie并在后续请求里携带它足以执行应用层的来源与 CSRF 判断。它不会执行真正的浏览器 SameSite 策略不会渲染攻击网页也不会模拟浏览器的 CORS 预检决策。因此答案中的“跨站请求”准确说是带有错误 Origin 的 HTTP 反例不是完整浏览器攻击复现。这并不降低实验价值只是限定证据。我们能证明即使请求已经到达应用并携带 Cookie服务端仍拒绝错误来源、缺失 CSRF 和错误 CSRF并且正确请求确实创建一条申请。部署验收若要证明浏览器与代理行为还需要真实浏览器、HTTPS 与实际来源配置的补充测试。本项目不提供浏览器专用刷新流程所以本课不会一边使用 Cookie一边暗中调用 Bearer refresh 并声称实现了自动续期。/auth/refresh发现请求仍带ps_accessCookie 时会返回 400明确要求令牌客户端使用这个入口。教学上先把两种凭据交付策略分别做清楚比把多个半成品流程拼成看似完整的登录页面更容易验证。本课练习 E05先拒绝三次再允许一次用 Alice 通过浏览器登录保留客户端 Cookie 和响应的 CSRF token。对同一个创建申请请求分别做三次负例错误 Origin 配正确 CSRF正确 Origin 但不带 CSRF正确 Origin 配错误 CSRF。每一次都应得到 403并且读取自己的申请列表确认仍为空。第四次携带正确 Origin 与正确 CSRF断言 201并确认列表只有这一张申请。再在保留 Cookie 的情况下添加 Authorization 头断言服务端返回 400验证它没有悄悄选择一套凭据。答案中不要为了让 Bearer 请求成功而无意清空 Cookie那会把关键的歧义输入抹掉。下一课将给出 E05 的完整代码接着把关注点从浏览器移到企业组织同一个有效会话为什么在管理员撤销经理角色后必须马上失去审批能力而不是一直等到 token 过期。上一课答案源码与本课完整 HTTP 入口模块E04 完整答案旧 refresh 重放后新 access 与新 refresh 一并失效。frompathlibimportPathimportsys sys.path.insert(0,str(Path(__file__).resolve().parents[1]))fromsupportimportlogin,sandboxdefmain():withsandbox()as(client,credentials):initiallogin(client,credentials,alice)old_refreshinitial[refresh_token]rotatedclient.post(/auth/refresh,json{refresh_token:old_refresh})assertrotated.status_code200currentrotated.json()assertcurrent[refresh_token]!old_refreshassertcurrent[access_token]!initial[access_token]current_headers{Authorization:Bearer current[access_token]}assertclient.get(/auth/me,headerscurrent_headers).status_code200# 关键是观察重放后的新凭据而不是只检查旧凭据本身被拒绝。replayclient.post(/auth/refresh,json{refresh_token:old_refresh})assertreplay.status_code401assertclient.get(/auth/me,headerscurrent_headers).status_code401assertclient.post(/auth/refresh,json{refresh_token:current[refresh_token]}).status_code401separatelogin(client,credentials,alice)separate_headers{Authorization:Bearer separate[access_token]}assertclient.get(/auth/me,headersseparate_headers).status_code200print(E04 PASS: refresh replay revokes the whole family)if__name____main__:main()HTTP层只负责解析与凭证选择采购授权交给同事务服务。importhmacimporttimeimportuuidfromtypingimportCallable,LiteralfromfastapiimportFastAPI,Request,Responsefromfastapi.exceptionsimportRequestValidationErrorfromfastapi.responsesimportJSONResponsefrom.importschemas,service,sessionsfrom.dbimportinitializefrom.errorsimportDomainError BROWSER_ORIGINhttp://localhost:8000ACCESS_COOKIEps_accessdefcreate_app(database:str,*,secure_cookies:boolTrue,clock:Callable[[],float]time.time,oidc_configNone,oidc_transportNone)-FastAPI:initialize(database)appFastAPI(titleProcureSafe,version1.0.0)app.state.databasedatabase app.state.clockclockapp.middleware(http)asyncdefcorrelation(request:Request,call_next):request.state.request_idstr(uuid.uuid4())ifrequest.cookies.get(ACCESS_COOKIE)andrequest.headers.get(Authorization):responseJSONResponse(status_code400,content{detail:Choose one credential transport})else:responseawaitcall_next(request)response.headers[X-Request-ID]request.state.request_id response.headers[Cache-Control]no-storeresponse.headers[X-Content-Type-Options]nosniffresponse.headers[Referrer-Policy]no-referrerreturnresponseapp.exception_handler(DomainError)asyncdefdomain_error(request:Request,exc:DomainError):contextgetattr(request.state,audit_context,None)ifcontextandexc.statusin(403,404,409):tenant,actor,action,resource_idcontext service.record_denial(database,tenant,actor,action,resource_id,request.state.request_id,clock)headers{WWW-Authenticate:Bearer}ifexc.status401elseNonereturnJSONResponse(status_codeexc.status,content{detail:exc.detail},headersheaders)app.exception_handler(RequestValidationError)asyncdefvalidation_error(request:Request,exc:RequestValidationError):# Pydantic默认错误含input认证接口必须移除输入避免回显口令/refresh。returnJSONResponse(status_code422,content{detail:[{location:list(error[loc]),type:error[type]}forerrorinexc.errors()]})defauthenticate(request:Request):cookie,authorizationrequest.cookies.get(ACCESS_COOKIE),request.headers.get(Authorization)ifcookieandauthorization:raiseDomainError(400,Choose one credential transport)ifauthorization:scheme,_,tokenauthorization.partition( )ifscheme.lower()!bearerornottokenor intoken:raiseDomainError(401,Authentication required)else:tokencookieorprincipalsessions.authenticate(database,token,clock)# 先建立可信actor的最小审计上下文随后CSRF拒绝也能留痕。paramsrequest.path_paramsiftenantinparamsandrequest.methodin(POST,PUT):action(member.updateifusernameinparamselserequest.params.get(action,create))request.state.audit_context(params[tenant],principal.username,action,params.get(resource_id,params.get(username)))ifcookieandrequest.methodnotin(GET,HEAD,OPTIONS):suppliedrequest.headers.get(X-CSRF-Token,)ifrequest.headers.get(Origin)!BROWSER_ORIGINornotsuppliedorlen(supplied)256:raiseDomainError(403,CSRF check failed)ifnothmac.compare_digest(sessions.token_digest(supplied),principal.csrf_hash):raiseDomainError(403,CSRF check failed)returnprincipaldefmutation_context(request,principal,tenant,action,resource_idNone):request.state.audit_context(tenant,principal.username,action,resource_id)app.get(/health)defhealth():return{status:ok}app.post(/auth/login)deflogin(body:schemas.Login):returnsessions.login(database,body.username,body.password,clock)app.post(/auth/browser/login)defbrowser_login(body:schemas.Login,request:Request,response:Response):ifrequest.headers.get(Origin)!BROWSER_ORIGIN:raiseDomainError(403,Origin forbidden)tokenssessions.login(database,body.username,body.password,clock)response.set_cookie(ACCESS_COOKIE,tokens[access_token],max_agesessions.ACCESS_TTL,httponlyTrue,securesecure_cookies,samesitelax,path/)returntokensapp.post(/auth/refresh)defrefresh(body:schemas.Refresh,request:Request):ifrequest.cookies.get(ACCESS_COOKIE):raiseDomainError(400,Browser refresh is not implemented; use the token client)returnsessions.refresh(database,body.refresh_token,clock)app.post(/auth/logout,status_code204)deflogout(request:Request):sessions.logout(database,authenticate(request),clock)responseResponse(status_code204)response.delete_cookie(ACCESS_COOKIE,path/,securesecure_cookies,httponlyTrue,samesitelax)returnresponseapp.get(/auth/me)defme(request:Request):principalauthenticate(request)return{id:principal.user_id,username:principal.username}app.post(/tenants/{tenant}/requests,status_code201)defcreate_request(tenant:str,body:schemas.NewRequest,request:Request):principalauthenticate(request)mutation_context(request,principal,tenant,request.create)returnservice.create_request(database,principal,tenant,body,request.state.request_id,clock)app.get(/tenants/{tenant}/requests)deflist_requests(tenant:str,request:Request):returnservice.list_requests(database,authenticate(request),tenant,clock)app.get(/tenants/{tenant}/requests/{resource_id})defget_request(tenant:str,resource_id:str,request:Request):returnservice.get_request(database,authenticate(request),tenant,resource_id,clock)app.post(/tenants/{tenant}/requests/{resource_id}/{action})deftransition(tenant:str,resource_id:str,action:Literal[approve,order,pay],body:schemas.Transition,request:Request):principalauthenticate(request)mutation_context(request,principal,tenant,request.action,resource_id)returnservice.transition(database,principal,tenant,resource_id,action,body.expected_version,request.state.request_id,clock)app.put(/tenants/{tenant}/members/{username})defupdate_member(tenant:str,username:str,body:schemas.MemberUpdate,request:Request):principalauthenticate(request)mutation_context(request,principal,tenant,member.update,username)returnservice.update_member(database,principal,tenant,username,body,request.state.request_id,clock)app.get(/tenants/{tenant}/audit)deflist_audit(tenant:str,request:Request):returnservice.list_audit(database,authenticate(request),tenant,clock)app.get(/platform/tenants)defplatform_tenants(request:Request):returnservice.list_tenants(database,authenticate(request),clock)ifoidc_configisnotNone:from.oidcimportregister_oidc register_oidc(app,database,oidc_config,clock,secure_cookies,oidc_transport)returnapp专题目录《认证、授权与企业级权限系统课程目录》 · 上一篇《Python后端权限系统04刷新不是续一张票——单次轮换、重放检测与事务竞争》 · 下一篇《Python后端权限系统06角色属于哪家公司——成员关系、管理范围与立即撤权》
阅读完成 · 觉得有帮助?
咨询建站