chore: readme 수정

This commit is contained in:
DongHyeonka
2026-07-29 18:03:21 +09:00
parent 8daa568746
commit 741c79b69f
180 changed files with 62890 additions and 13 deletions
@@ -0,0 +1,25 @@
# AP3의 masked CSRF 응답과 raw POST credential
## Alternative text
BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.
## Long description
왼쪽의 BFF CSRF endpoint에서 두 결과가 갈라진다. XSRF-TOKEN cookie에는 raw token이 저장되고 JSON body에는 XOR와 Base64로 masked된 token 및 headerName이 담긴다. 두 결과는 SPA의 POST 조립 단계로 모이지만, JSON에서는 headerName만 사용하고 실제 X-XSRF-TOKEN 값은 document.cookie에서 읽은 raw token이다. POST에는 같은 raw 값을 가진 cookie와 header가 함께 도달하고 Spring CSRF filter가 일치 여부를 확인한다.
## Elements and evidence
- **BFF · /bff/csrf** (service): CookieCsrfTokenRepository와 CsrfController를 통해 raw cookie와 masked JSON 응답을 만드는 endpoint. Evidence: L819L844.
- **Browser cookie · raw** (credential): Cookie repository가 path /에 설정하며 SPA가 document.cookie로 읽는 raw CSRF token. Evidence: L827L834, L844L856.
- **JSON body · masked** (data): Request attribute용 token을 masked한 JSON 표현으로, SPA는 token 값이 아니라 headerName만 사용한다. Evidence: L836L846.
- **SPA POST 조립** (component): Raw cookie 값을 X-XSRF-TOKEN header에 넣어 preference POST를 만드는 browser code. Evidence: L846L856, L862L870.
- **Spring CSRF filter** (security): Repository의 expected raw token과 submitted raw header를 controller 실행 전에 비교하는 방어선. Evidence: L854L854, L873L873, L897L904.
## Relationships
- **SPA POST 조립 → Spring CSRF filter:** Cookie raw = Header raw. Evidence: L848L873.
- **BFF · /bff/csrf → JSON body · masked:** masked JSON. Evidence: L836L844.
- **BFF · /bff/csrf → Browser cookie · raw:** Set-Cookie · raw. Evidence: L827L834.
- **JSON body · masked → SPA POST 조립:** headerName only. Evidence: L846L856.
- **Browser cookie · raw → SPA POST 조립:** document.cookie · raw. Evidence: L846L856.
@@ -0,0 +1,23 @@
# AP3의 masked CSRF 응답과 raw POST credential
# Question: AP3에서 CSRF 응답의 masked token과 raw cookie 중 무엇이 실제 POST header의 data source가 되는가?
direction: right
n0: "BFF · /bff/csrf" {
shape: rectangle
}
n1: "Browser cookie · raw" {
shape: rectangle
}
n2: "JSON body · masked" {
shape: rectangle
}
n3: "SPA POST 조립" {
shape: rectangle
}
n4: "Spring CSRF filter" {
shape: rectangle
}
n0 -> n1: "Set-Cookie · raw"
n0 -> n2: "masked JSON"
n1 -> n3: "document.cookie · raw"
n2 -> n3: "headerName only"
n3 -> n4: "Cookie raw = Header raw"
@@ -0,0 +1,15 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="BFF · /bff/csrf", shape=box, style="rounded,filled"];
n1 [label="Browser cookie · raw", shape=box, style="rounded,filled"];
n2 [label="JSON body · masked", shape=box, style="rounded,filled"];
n3 [label="SPA POST 조립", shape=box, style="rounded,filled"];
n4 [label="Spring CSRF filter", shape=box, style="rounded,filled"];
n0 -> n1 [label="Set-Cookie · raw", style=solid];
n0 -> n2 [label="masked JSON", style=solid];
n1 -> n3 [label="document.cookie · raw", style=solid];
n2 -> n3 [label="headerName only", style=solid];
n3 -> n4 [label="Cookie raw = Header raw", style=solid];
}
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="ap3-csrf-boundary" name="AP3의 masked CSRF 응답과 raw POST credential">
<mxGraphModel dx="1411" dy="392" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1411" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_csrf-endpoint" value="BFF · /bff/csrf" tooltip="CookieCsrfTokenRepository와 CsrfController를 통해 raw cookie와 masked JSON 응답을 만드는 endpoint. | Evidence: L819-L844" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="171.5" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_masked-json" value="JSON body · masked&lt;br/&gt;token = XOR/Base64&lt;br/&gt;headerName metadata&lt;br/&gt;POST token 값으로 미사용" tooltip="Request attribute용 token을 masked한 JSON 표현으로, SPA는 token 값이 아니라 headerName만 사용한다. | Evidence: L836-L846" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=document;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="380.0" y="60.0" width="220.0" height="110.0" as="geometry"/>
</mxCell>
<mxCell id="n_raw-cookie" value="Browser cookie · raw&lt;br/&gt;XSRF-TOKEN&lt;br/&gt;JavaScript-readable&lt;br/&gt;실제 header data source" tooltip="Cookie repository가 path /에 설정하며 SPA가 document.cookie로 읽는 raw CSRF token. | Evidence: L827-L834, L844-L856" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="399.5" y="242.0" width="181.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_post-assembler" value="SPA POST 조립&lt;br/&gt;Cookie 자동 첨부&lt;br/&gt;document.cookie raw → header&lt;br/&gt;JSON headerName만 사용" tooltip="Raw cookie 값을 X-XSRF-TOKEN header에 넣어 preference POST를 만드는 browser code. | Evidence: L846-L856, L862-L870" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="760.0" y="151.0" width="230.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_csrf-filter" value="Spring CSRF filter&lt;br/&gt;raw cookie = raw header 비교&lt;br/&gt;일치 → controller&lt;br/&gt;부재·불일치 → 403" tooltip="Repository의 expected raw token과 submitted raw header를 controller 실행 전에 비교하는 방어선. | Evidence: L854-L854, L873-L873, L897-L904" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1150.0" y="151.0" width="216.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="e_assembler-to-filter" value="Cookie raw = Header raw" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_post-assembler" target="n_csrf-filter">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1070.0" y="175.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_endpoint-to-masked-json" value="masked JSON" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_csrf-endpoint" target="n_masked-json">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="154.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_endpoint-to-raw-cookie" value="Set-Cookie · raw" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_csrf-endpoint" target="n_raw-cookie">
<mxGeometry relative="1" as="geometry">
<mxPoint x="333.8" y="253.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_masked-json-to-assembler" value="headerName only" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_masked-json" target="n_post-assembler">
<mxGeometry relative="1" as="geometry">
<mxPoint x="704.0" y="154.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_raw-cookie-to-assembler" value="document.cookie · raw" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_raw-cookie" target="n_post-assembler">
<mxGeometry relative="1" as="geometry">
<mxPoint x="694.2" y="253.5" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,853 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-assembler-to-filter",
"type": "arrow",
"x": 990.0,
"y": 203.5,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 722742849,
"version": 1,
"versionNonce": 364630411,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-post-assembler",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-csrf-filter",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-assembler-to-filter",
"type": "text",
"x": 978.0,
"y": 163.5,
"width": 184,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 337637491,
"version": 1,
"versionNonce": 1382239179,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Cookie raw = Header raw",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Cookie raw = Header raw",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-endpoint-to-masked-json",
"type": "arrow",
"x": 220.0,
"y": 115.0,
"width": 160.0,
"height": 79.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1241633364,
"version": 1,
"versionNonce": 795935518,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
79.5
],
[
80.0,
79.5
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-csrf-endpoint",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-masked-json",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-endpoint-to-masked-json",
"type": "text",
"x": 279.0,
"y": 142.75,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 367644097,
"version": 1,
"versionNonce": 1658155106,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "masked JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "masked JSON",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-endpoint-to-raw-cookie",
"type": "arrow",
"x": 220.0,
"y": 212.5,
"width": 179.5,
"height": 82.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 972011859,
"version": 1,
"versionNonce": 1702387953,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
89.75,
0.0
],
[
89.75,
82.0
],
[
179.5,
82.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-csrf-endpoint",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-raw-cookie",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-endpoint-to-raw-cookie",
"type": "text",
"x": 269.75,
"y": 241.5,
"width": 128,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 909441863,
"version": 1,
"versionNonce": 329759881,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Set-Cookie · raw",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Set-Cookie · raw",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-masked-json-to-assembler",
"type": "arrow",
"x": 600.0,
"y": 115.0,
"width": 160.0,
"height": 79.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1214135570,
"version": 1,
"versionNonce": 472532934,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
79.5
],
[
160.0,
79.5
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-masked-json",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-post-assembler",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-masked-json-to-assembler",
"type": "text",
"x": 644.0,
"y": 142.75,
"width": 120,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 200978920,
"version": 1,
"versionNonce": 1107821704,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "headerName only",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "headerName only",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-raw-cookie-to-assembler",
"type": "arrow",
"x": 580.5,
"y": 212.5,
"width": 179.5,
"height": 82.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1497085764,
"version": 1,
"versionNonce": 1398757375,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
82.0
],
[
89.75,
82.0
],
[
89.75,
0.0
],
[
179.5,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-raw-cookie",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-post-assembler",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-raw-cookie-to-assembler",
"type": "text",
"x": 610.25,
"y": 241.5,
"width": 168,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1946436355,
"version": 1,
"versionNonce": 849590995,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "document.cookie · raw",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "document.cookie · raw",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-csrf-endpoint",
"type": "rectangle",
"x": 70.0,
"y": 171.5,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 984730913,
"version": 1,
"versionNonce": 1093344604,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-csrf-endpoint",
"type": "text",
"x": 80.0,
"y": 181.5,
"width": 130.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1042231595,
"version": 1,
"versionNonce": 1571377378,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "BFF · /bff/csrf",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "BFF · /bff/csrf",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-masked-json",
"type": "rectangle",
"x": 380.0,
"y": 60.0,
"width": 220.0,
"height": 110.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 650571311,
"version": 1,
"versionNonce": 1000688126,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-masked-json",
"type": "text",
"x": 390.0,
"y": 70.0,
"width": 200.0,
"height": 90.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 807523454,
"version": 1,
"versionNonce": 1583912655,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST token 값으로 미사용",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST token 값으로 미사용",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-raw-cookie",
"type": "rectangle",
"x": 399.5,
"y": 242.0,
"width": 181.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 754250876,
"version": 1,
"versionNonce": 800528172,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-raw-cookie",
"type": "text",
"x": 409.5,
"y": 252.0,
"width": 161.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 363576130,
"version": 1,
"versionNonce": 808943536,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Browser cookie · raw\nXSRF-TOKEN\nJavaScript-readable\n실제 header data source",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser cookie · raw\nXSRF-TOKEN\nJavaScript-readable\n실제 header data source",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-post-assembler",
"type": "rectangle",
"x": 760.0,
"y": 151.0,
"width": 230.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1624016171,
"version": 1,
"versionNonce": 1299949724,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-post-assembler",
"type": "text",
"x": 770.0,
"y": 161.0,
"width": 210.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1469453706,
"version": 1,
"versionNonce": 539214682,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "SPA POST 조립\nCookie 자동 첨부\ndocument.cookie raw → header\nJSON headerName만 사용",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "SPA POST 조립\nCookie 자동 첨부\ndocument.cookie raw → header\nJSON headerName만 사용",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-csrf-filter",
"type": "rectangle",
"x": 1150.0,
"y": 151.0,
"width": 216.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1449308597,
"version": 1,
"versionNonce": 456209351,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-csrf-filter",
"type": "text",
"x": 1160.0,
"y": 161.0,
"width": 196.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 399971796,
"version": 1,
"versionNonce": 1957873210,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring CSRF filter\nraw cookie = raw header 비교\n일치 → controller\n부재·불일치 → 403",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring CSRF filter\nraw cookie = raw header 비교\n일치 → controller\n부재·불일치 → 403",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap3-csrf-boundary",
"spec_version": "1.1",
"spec_sha256": "fe7eed6a9b09c9ca13e32c04d27d1b245676e405bd3f432e363794918772e2b9",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap3-csrf-boundary",
"line": 858
}
},
"outputs": [
"ap3-csrf-boundary.svg",
"ap3-csrf-boundary.drawio",
"ap3-csrf-boundary.mmd",
"ap3-csrf-boundary.d2",
"ap3-csrf-boundary.dot",
"ap3-csrf-boundary.excalidraw",
"ap3-csrf-boundary.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,13 @@
%% AP3의 masked CSRF 응답과 raw POST credential
%% question: AP3에서 CSRF 응답의 masked token과 raw cookie 중 무엇이 실제 POST header의 data source가 되는가?
flowchart LR
n0["BFF · /bff/csrf"]
n1["Browser cookie · raw"]
n2["JSON body · masked"]
n3["SPA POST 조립"]
n4["Spring CSRF filter"]
n0 -->|"Set-Cookie · raw"| n1
n0 -->|"masked JSON"| n2
n1 -->|"document.cookie · raw"| n3
n2 -->|"headerName only"| n3
n3 -->|"Cookie raw = Header raw"| n4
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1411" height="392" viewBox="0 0 1411 392" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP3의 masked CSRF 응답과 raw POST credential</title>
<desc id="diagram-description">왼쪽의 BFF CSRF endpoint에서 두 결과가 갈라진다. XSRF-TOKEN cookie에는 raw token이 저장되고 JSON body에는 XOR와 Base64로 masked된 token 및 headerName이 담긴다. 두 결과는 SPA의 POST 조립 단계로 모이지만, JSON에서는 headerName만 사용하고 실제 X-XSRF-TOKEN 값은 document.cookie에서 읽은 raw token이다. POST에는 같은 raw 값을 가진 cookie와 header가 함께 도달하고 Spring CSRF filter가 일치 여부를 확인한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-csrf-boundary&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap3-csrf-boundary&quot;,&quot;line&quot;:858}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1411" height="392" />
<polyline class="edge kind-request style-solid emphasis-primary" points="990.0,203.5 1070.0,203.5 1070.0,203.5 1150.0,203.5" data-evidence="848-873" />
<rect class="edge-label-bg" x="984.0" y="161.5" width="172.1" height="22" rx="3" />
<text class="edge-label" x="1070.0" y="176.5">Cookie raw = Header raw</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,194.5 300.0,194.5 300.0,115.0 380.0,115.0" data-evidence="836-844" />
<rect class="edge-label-bg" x="278.1" y="140.8" width="91.7" height="22" rx="3" />
<text class="edge-label" x="324.0" y="155.8">masked JSON</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,212.5 309.8,212.5 309.8,294.5 399.5,294.5" data-evidence="827-834" />
<rect class="edge-label-bg" x="271.1" y="239.5" width="125.2" height="22" rx="3" />
<text class="edge-label" x="333.8" y="254.5">Set-Cookie · raw</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="600.0,115.0 680.0,115.0 680.0,194.5 760.0,194.5" data-evidence="846-856" />
<rect class="edge-label-bg" x="644.8" y="140.8" width="118.5" height="22" rx="3" />
<text class="edge-label" x="704.0" y="155.8">headerName only</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="580.5,294.5 670.2,294.5 670.2,212.5 760.0,212.5" data-evidence="846-856" />
<rect class="edge-label-bg" x="614.9" y="239.5" width="158.7" height="22" rx="3" />
<text class="edge-label" x="694.2" y="254.5">document.cookie · raw</text>
<g id="node-csrf-endpoint">
<rect class="node-shape kind-service emphasis-normal role-source" data-evidence="819-844" x="70.0" y="171.5" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="145.0" y="201.5">BFF · /bff/csrf</text>
</g>
<g id="node-masked-json">
<path class="node-shape kind-data emphasis-normal role-store" data-evidence="836-846" d="M 380.0 60.0 H 569.2 L 600.0 90.8 V 170.0 H 380.0 Z M 569.2 60.0 V 90.8 H 600.0" />
<text class="node-label" x="490.0" y="89.0">JSON body · masked</text>
<line class="node-detail-divider" x1="394.0" y1="110.0" x2="586.0" y2="110.0" />
<text class="node-detail" x="396.0" y="127.0">token = XOR/Base64</text>
<text class="node-detail" x="396.0" y="143.0">headerName metadata</text>
<text class="node-detail" x="396.0" y="159.0">POST token 값으로 미사용</text>
</g>
<g id="node-raw-cookie">
<rect class="node-shape kind-credential emphasis-primary role-store" data-evidence="827-834,844-856" x="399.5" y="242.0" width="181.0" height="105.0" rx="7" />
<text class="node-label" x="490.0" y="269.0">Browser cookie · raw</text>
<line class="node-detail-divider" x1="413.5" y1="290.0" x2="566.5" y2="290.0" />
<text class="node-detail" x="415.5" y="307.0">XSRF-TOKEN</text>
<text class="node-detail" x="415.5" y="323.0">JavaScript-readable</text>
<text class="node-detail" x="415.5" y="339.0">실제 header data source</text>
</g>
<g id="node-post-assembler">
<rect class="node-shape kind-component emphasis-normal role-service" data-evidence="846-856,862-870" x="760.0" y="151.0" width="230.0" height="105.0" rx="7" />
<text class="node-label" x="875.0" y="178.0">SPA POST 조립</text>
<line class="node-detail-divider" x1="774.0" y1="199.0" x2="976.0" y2="199.0" />
<text class="node-detail" x="776.0" y="216.0">Cookie 자동 첨부</text>
<text class="node-detail" x="776.0" y="232.0">document.cookie raw → header</text>
<text class="node-detail" x="776.0" y="248.0">JSON headerName만 사용</text>
</g>
<g id="node-csrf-filter">
<rect class="node-shape kind-security emphasis-normal role-sink" data-evidence="854-854,873-873,897-904" x="1150.0" y="151.0" width="216.0" height="105.0" rx="7" />
<text class="node-label" x="1258.0" y="178.0">Spring CSRF filter</text>
<line class="node-detail-divider" x1="1164.0" y1="199.0" x2="1352.0" y2="199.0" />
<text class="node-detail" x="1166.0" y="216.0">raw cookie = raw header 비교</text>
<text class="node-detail" x="1166.0" y="232.0">일치 → controller</text>
<text class="node-detail" x="1166.0" y="248.0">부재·불일치 → 403</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB