@milcho0604/velog-mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +366 -0
  3. package/README.md +381 -0
  4. package/dist/auth.d.ts +57 -0
  5. package/dist/auth.js +124 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/capabilities.d.ts +50 -0
  8. package/dist/capabilities.js +60 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/client.d.ts +113 -0
  11. package/dist/client.js +322 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/format.d.ts +31 -0
  14. package/dist/format.js +66 -0
  15. package/dist/format.js.map +1 -0
  16. package/dist/graphql.d.ts +29 -0
  17. package/dist/graphql.js +82 -0
  18. package/dist/graphql.js.map +1 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.js +149 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/me.d.ts +23 -0
  23. package/dist/me.js +35 -0
  24. package/dist/me.js.map +1 -0
  25. package/dist/ownership.d.ts +42 -0
  26. package/dist/ownership.js +62 -0
  27. package/dist/ownership.js.map +1 -0
  28. package/dist/plugin-env.d.ts +67 -0
  29. package/dist/plugin-env.js +102 -0
  30. package/dist/plugin-env.js.map +1 -0
  31. package/dist/ratelimit.d.ts +48 -0
  32. package/dist/ratelimit.js +79 -0
  33. package/dist/ratelimit.js.map +1 -0
  34. package/dist/render/chrome.d.ts +77 -0
  35. package/dist/render/chrome.js +287 -0
  36. package/dist/render/chrome.js.map +1 -0
  37. package/dist/render/cover.d.ts +29 -0
  38. package/dist/render/cover.js +195 -0
  39. package/dist/render/cover.js.map +1 -0
  40. package/dist/render/icons.d.ts +22 -0
  41. package/dist/render/icons.js +158 -0
  42. package/dist/render/icons.js.map +1 -0
  43. package/dist/render/index.d.ts +32 -0
  44. package/dist/render/index.js +137 -0
  45. package/dist/render/index.js.map +1 -0
  46. package/dist/render/page.d.ts +89 -0
  47. package/dist/render/page.js +761 -0
  48. package/dist/render/page.js.map +1 -0
  49. package/dist/render/tones.d.ts +30 -0
  50. package/dist/render/tones.js +46 -0
  51. package/dist/render/tones.js.map +1 -0
  52. package/dist/slug.d.ts +42 -0
  53. package/dist/slug.js +79 -0
  54. package/dist/slug.js.map +1 -0
  55. package/dist/tools/discover.d.ts +6 -0
  56. package/dist/tools/discover.js +106 -0
  57. package/dist/tools/discover.js.map +1 -0
  58. package/dist/tools/drafts.d.ts +23 -0
  59. package/dist/tools/drafts.js +227 -0
  60. package/dist/tools/drafts.js.map +1 -0
  61. package/dist/tools/export.d.ts +21 -0
  62. package/dist/tools/export.js +132 -0
  63. package/dist/tools/export.js.map +1 -0
  64. package/dist/tools/images.d.ts +34 -0
  65. package/dist/tools/images.js +556 -0
  66. package/dist/tools/images.js.map +1 -0
  67. package/dist/tools/posts.d.ts +14 -0
  68. package/dist/tools/posts.js +82 -0
  69. package/dist/tools/posts.js.map +1 -0
  70. package/dist/tools/profile-edit.d.ts +15 -0
  71. package/dist/tools/profile-edit.js +216 -0
  72. package/dist/tools/profile-edit.js.map +1 -0
  73. package/dist/tools/profile.d.ts +9 -0
  74. package/dist/tools/profile.js +133 -0
  75. package/dist/tools/profile.js.map +1 -0
  76. package/dist/tools/publish.d.ts +16 -0
  77. package/dist/tools/publish.js +424 -0
  78. package/dist/tools/publish.js.map +1 -0
  79. package/dist/tools/stats.d.ts +32 -0
  80. package/dist/tools/stats.js +154 -0
  81. package/dist/tools/stats.js.map +1 -0
  82. package/dist/types.d.ts +42 -0
  83. package/dist/types.js +3 -0
  84. package/dist/types.js.map +1 -0
  85. package/docs/PRD.md +146 -0
  86. package/docs/api-reference.md +329 -0
  87. package/docs/architecture.md +112 -0
  88. package/docs/decisions/0001-why-build-our-own.md +89 -0
  89. package/docs/decisions/0002-draft-only-write.md +84 -0
  90. package/docs/decisions/0003-token-env-only.md +109 -0
  91. package/docs/decisions/0004-capability-model.md +123 -0
  92. package/docs/decisions/0005-render-in-server.md +117 -0
  93. package/docs/decisions/0006-ship-as-plugin.md +532 -0
  94. package/docs/security.md +384 -0
  95. package/docs/tools.md +404 -0
  96. package/npm-shrinkwrap.json +2345 -0
  97. package/package.json +61 -0
@@ -0,0 +1,384 @@
1
+ # 보안 설계
2
+
3
+ 이 서버는 **벨로그 계정 자격증명**을 다루고 **AI 모델이 호출**한다.
4
+ 두 조건이 겹치므로 "무엇을 할 수 있나"보다 **"최악의 경우 무엇까지 가능한가"**
5
+ 를 기준으로 설계했다.
6
+
7
+ ## 권한 모델
8
+
9
+ ```
10
+ 기본 (설정 없음) 읽기 + 초안 + 비공개 발행
11
+ VELOG_ALLOW_PUBLIC=1 공개 발행
12
+ ```
13
+
14
+ **스위치는 환경변수다. 모델이 못 건드린다.** MCP 설정 파일을 여는 사람만 바꿀 수
15
+ 있고, 이는 토큰을 넣는 것과 같은 신뢰 경계다. → [ADR 0004](decisions/0004-capability-model.md)
16
+
17
+ 구현이 이 경계를 두 겹으로 지킨다:
18
+
19
+ | 층 | 역할 |
20
+ | --- | --- |
21
+ | 스키마 | 설정이 꺼져 있으면 `is_private` 파라미터가 **아예 없다** — 요청할 방법이 없음 |
22
+ | 런타임 | 혹시 넘어와도 `resolvePrivacy` 가 `true` 로 확정 |
23
+
24
+ `1`·`true`·`yes`·`on` 만 '켬'으로 인정한다. 오타로 조용히 켜지지 않는다.
25
+
26
+ ## 능력 상한
27
+
28
+ **기본 설정에서** 이 서버가 저지를 수 있는 일:
29
+
30
+ > **① 비공개 글이 몇 개 생긴다.** (사용자가 지우면 원복. 남에게 보인 적 없음)
31
+ >
32
+ > **② 기존 공개 글을 수정·비공개화·초안화할 수 있다.**
33
+ > 공개 발행 권한이 없어도 `velog_update_post` 로 수정하면 비공개로 내려가고,
34
+ > `velog_unpublish_post` 로 초안으로 되돌릴 수 있다. **글이 사라지지는 않지만
35
+ > 남들에게 안 보이게 된다.** 되돌리려면 사용자가 벨로그에서 다시 공개해야 한다.
36
+ > 이건 '공개로 만드는 권한'이 아니라 '이미 공개인 걸 감추는' 방향이라 별도
37
+ > 게이트를 두지 않았지만, 상한에는 포함되어야 한다.
38
+ >
39
+ > ⚠️ 그리고 **드물게** — 사용자가 이미 최근 5분에 공개 글을 10건 올려둔 상태라면,
40
+ > 우리가 보내는 비공개 요청 하나가 벨로그의 파괴 동작(그 시간대 글 전부 비공개화)을
41
+ > **촉발할 수 있다.** 우리 글이 계수를 올려서가 아니라, 검사가 공개 여부를 보기 전에
42
+ > 무조건 돌기 때문이다. 아래 §대응 참고.
43
+
44
+ `VELOG_ALLOW_PUBLIC=1` 을 켜면 상한이 올라간다 — 공개 발행은 RSS·검색 색인·
45
+ 구독 메일로 나가므로 지워도 회수되지 않는다.
46
+ `VELOG_ALLOW_PROFILE=1` 은 프로필·소개글·블로그제목·SNS·프로필사진을 연다 —
47
+ 전부 되돌릴 수 있고 배포되지 않아 상한이 크게 오르지는 않는다.
48
+
49
+ 아래는 **처음엔 틀렸다가 고치고 나서야 사실이 된 것**이다.
50
+ 경위를 남겨둔다 — 같은 착각을 다시 하지 않기 위해서다.
51
+
52
+ ### 초안이 '발행된 글'을 비공개로 만들 수 있었다
53
+
54
+ 벨로그 공식 구현(`apps/server/src/services/PostApiService/index.mts`):
55
+
56
+ ```ts
57
+ private async isPostLimitReached(signedUserId) {
58
+ const recentPostCount = await db.post.count({
59
+ where: { fk_user_id, is_private: false, // ← is_temp 구분 없음
60
+ released_at: { gt: 5분전 } } })
61
+ if (recentPostCount < 10) return false
62
+ await db.post.updateMany({
63
+ where: { fk_user_id, released_at: { gt: 5분전 } }, // ← is_private 필터도 없음
64
+ data: { is_private: true } }) // ← 최근 5분 글 전부 비공개
65
+ }
66
+ ```
67
+
68
+ 그리고 `schema.prisma`:
69
+
70
+ ```prisma
71
+ released_at DateTime? @default(now()) // 초안도 생성 즉시 시각이 붙는다
72
+ ```
73
+
74
+ 두 사실이 겹치면 — **초안을 5분에 10개 만들면 그 시간대에 발행한 진짜 글이
75
+ 비공개로 내려간다.** 초안은 되돌릴 수 있지만 이건 사용자가 글마다 공개 설정을
76
+ 다시 손봐야 한다. "최악은 비공개 초안"이라는 전제가 깨진 지점이다.
77
+
78
+ **대응 — 처음엔 방어를 얹었고, 나중에 근본을 고쳤다:**
79
+
80
+ | 조치 | 이유 |
81
+ | --- | --- |
82
+ | 쓰기는 **재시도하지 않는다** (`client.mutate`) | 멱등하지 않다. 응답만 유실돼도 재시도가 글을 하나 더 만들어 한계를 앞당긴다 |
83
+ | 공개 발행 **5분 5건** 상한 (`ratelimit.ts`) | 벨로그 임계 10보다 낮게 잡는다 — 사용자가 웹에서 직접 쓴 글은 우리 카운터에 안 잡히므로 여유가 필요하다 |
84
+ | ★ **초안을 `is_private: true` 로** | 계수 대상이 `is_private:false` 뿐이라 초안이 카운터를 **올리지 않는다**. ⚠️단 검사는 공개 여부를 보기 전에 무조건 돌아, 이미 공개 글 10건이 쌓였으면 비공개 요청도 조치를 **촉발할 수 있다** — 그래서 위 두 방어를 없애지 않았다 |
85
+
86
+ 세 번째가 **가장 효과가 크다.** 방어를 하나 더 얹는 것보다 애초에 위험 구간에
87
+ 덜 들어가는 값을 고르는 편이 낫고, 초안은 어차피 본인만 보므로 잃는 것도 없다.
88
+
89
+ 다만 **근본 해결은 아니다.** 처음엔 그렇게 적었다가 정정했다 — 우리 글이 계수를
90
+ 올리지 않을 뿐, 요청 자체는 이미 쌓인 계수에 대한 조치를 촉발할 수 있다.
91
+ 그래서 앞의 두 방어(무재시도·상한)를 **없애지 않고 유지한다.** 상한은 공개 발행
92
+ 경로에 남아 있다.
93
+
94
+ 막을 때는 이유와 해제 시각을 함께 알린다. 조용히 거절하면 사용자가 원인을 못 찾는다.
95
+
96
+ ## 구현하지 않은 것과 이유
97
+
98
+ introspection 으로 확인한 mutation 23개의 처리를 셋으로 나눈다.
99
+
100
+ **(1) 기본으로 쓰는 것** — `writePost` / `editPost`.
101
+ `is_temp`·`is_private` 조합으로 초안·비공개 발행·공개 발행·발행취소를 모두 처리한다.
102
+ 공개 여부만 `VELOG_ALLOW_PUBLIC` 게이트를 탄다.
103
+
104
+ **(2) 게이트로 여는 것** — `VELOG_ALLOW_PROFILE=1` 일 때만 도구로 등록한다.
105
+ `updateProfile` / `updateAbout` / `updateVelogTitle` / `updateSocialInfo` /
106
+ `updateThumbnail`. 되돌릴 수 있고 본인 계정에만 영향이며 배포되지 않는다.
107
+ 게이트를 둔 이유는 위험이 아니라 혼동이다 → [ADR 0004](decisions/0004-capability-model.md)
108
+
109
+ **(3) 어떤 설정으로도 안 여는 것** — 아래. 목록에서 뺀 게 아니라 **호출 코드를 안 썼다.**
110
+
111
+ | mutation | 뺀 이유 |
112
+ | --- | --- |
113
+ | `unregister` | **계정 탈퇴.** 복구 불가. 존재 자체가 위험 |
114
+ | `logout` | 세션 파괴 |
115
+ | `likePost` `unlikePost` | 남의 알림에 내 이름이 뜬다 |
116
+ | `follow` `unfollow` | 같음 |
117
+ | `sendMail` | 메일 발송 |
118
+ | `createNotification` | 알림 생성 |
119
+ | `removeAllNotifications` | 일괄 삭제. 되돌릴 수 없음 |
120
+ | `readNotification` `readAllNotifications` `updateNotNoticeNotification` | 읽음 상태 변경 |
121
+ | `updateEmailRules` | 메일 수신 설정 |
122
+ | `initiateChangeEmail` `confirmChangeEmail` | 이메일 변경 = 계정 탈취 경로 |
123
+ | `acceptIntegration` | 외부 연동 승인 |
124
+
125
+ > `deletePost` 는 v3 mutation 목록에 **없다.** 글 삭제는 애초에 불가능하다.
126
+
127
+ ## `is_temp` 를 상수로 박는 이유
128
+
129
+ 발행과 임시저장은 별도 mutation 이 아니다. **같은 mutation 의 불린 하나**로 갈린다.
130
+
131
+ ```
132
+ writePost(input: { ..., is_temp: true }) → 임시저장 (남에게 안 보임)
133
+ writePost(input: { ..., is_temp: false }) → 발행 (되돌릴 수 없음)
134
+ ```
135
+
136
+ 그래서 "발행 도구를 안 만든다"로는 부족하다. `is_temp` 를 파라미터로 받는 순간
137
+ 모델이 `false` 를 넣을 수 있다. **값을 상수로 박아야** 호출 경로가 발행에
138
+ 도달할 수 없다.
139
+
140
+ ```ts
141
+ const DRAFT_ONLY = { is_temp: true } as const;
142
+ // 도구 입력 스키마에 is_temp 키가 없다 — 받지 않으므로 덮어쓸 수 없다
143
+ ```
144
+
145
+ 이건 정책이 아니라 구조다. 프롬프트로 "발행하지 마세요"라고 말하는 것과
146
+ 호출 경로가 존재하지 않는 것은 다르다. → [ADR 0002](decisions/0002-draft-only-write.md)
147
+
148
+ ## 토큰 취급
149
+
150
+ | 규칙 | 이유 |
151
+ | --- | --- |
152
+ | 환경변수로만 받는다 | 프로세스 수명만큼만 존재 |
153
+ | **디스크에 쓰지 않는다** | 30일 자격증명이 파일로 남으면 백업·동기화·스캔 범위에 계속 노출 |
154
+ | **브라우저 쿠키 DB 를 읽지 않는다** | Chrome 마스터 키 접근이라는 능력 상한을 붙이지 않기 위해 |
155
+ | **macOS 키체인을 건드리지 않는다** | 같음 |
156
+ | 로그·에러에 싣지 않는다 | GraphQL 에러를 그대로 던지면 Cookie 헤더가 섞일 수 있어 마스킹 |
157
+ | 없으면 읽기 전용으로 기동 | 토큰 없음은 에러가 아니라 상태 |
158
+ | 갱신 토큰도 메모리에만 | 서버가 Set-Cookie 로 주는 새 토큰을 받되 디스크엔 안 쓴다 |
159
+ | 갱신 **전** 토큰도 계속 마스킹 | 옛 토큰이 나중에 로그로 새면 갱신한 의미가 없다 |
160
+
161
+ → [ADR 0003](decisions/0003-token-env-only.md)
162
+
163
+ ## 네트워크
164
+
165
+ 접속하는 호스트는 **벨로그 하나뿐**이다.
166
+
167
+ ```
168
+ https://v3.velog.io/graphql 질의·수정
169
+ https://v3.velog.io/api/files/v3/upload 이미지 업로드 (GraphQL 에 업로드가 없다)
170
+ ```
171
+
172
+ 텔레메트리·분석·업데이트 확인 등 다른 호스트로 나가는 요청이 없다.
173
+ 런타임 의존성이 2개(`@modelcontextprotocol/sdk`, `zod`)뿐인 것도 이 보장을
174
+ 검증 가능한 크기로 유지하기 위해서다.
175
+
176
+ 자격증명이 나가는 목적지는 **코드가 확인한다.** 엔드포인트는 테스트 주입용으로
177
+ 바꿀 수 있게 돼 있는데, 임의 주소가 들어오면 그 호스트로 쿠키가 나간다.
178
+ 그래서 GraphQL 경로와 업로드 경로 둘 다 "정규 주소가 아니면 자격증명을 싣지 않고
179
+ 요청 자체를 거부한다"를 실행 시점에 검사한다. 문자열 검색 테스트로는 못 막는 것이다.
180
+
181
+ ## 그림 기능 — 늘어난 공격면과 대응
182
+
183
+ 렌더 기능은 이 서버에서 성격이 다른 유일한 부분이다. **브라우저를 띄우고**,
184
+ **로컬 파일을 읽고**, **공개 CDN 으로 바이트를 내보낸다.** 각각에 대응이 있다.
185
+
186
+ ### ① 브라우저에서 임의 코드가 돌지 않는가
187
+
188
+ 모델은 HTML 을 못 준다. 받는 건 데이터(노드·엣지·색 이름)뿐이고, 페이지는 그 데이터를
189
+ `<script type="application/json">` 블록으로 읽어 **`createElementNS` + `textContent`**
190
+ 로만 DOM 을 만든다. `innerHTML`·`document.write` 는 렌더 모듈 어디에도 없다(R3 이 강제).
191
+ JSON 을 실을 때 `<` 를 `\u003c` 로 바꾸므로 `</script>` 가 만들어질 수 없다(R2).
192
+
193
+ 색은 톤 이름 아니면 `#rrggbb` 만 받는다. `url(...)`·`var(...)` 같은 함수 표기를
194
+ 막기 위해서다(R4).
195
+
196
+ ### ② 렌더 페이지가 밖으로 나갈 수 있는가
197
+
198
+ **1차 방어는 '나갈 구멍이 없다'는 것이다.** 페이지에는 URL 을 받는 자리가 하나도 없다 —
199
+ 아이콘은 내장 도형이고, `href`·`src`·`fetch` 를 쓰지 않으며, `url(...)` 은 문서 안
200
+ marker 참조(`url(#arr-…)`) 뿐이다. 그 marker 이름조차 `[A-Za-z0-9_-]{1,16}` 으로
201
+ 좁혀져 있어 참조식을 벗어날 수 없다. R12 가 이걸 소스에서 강제한다.
202
+
203
+ **2차 방어가 `--host-resolver-rules=MAP * ~NOTFOUND` 다.** 나중에 누가 실수로 원격
204
+ 리소스를 넣어도 막힌다. 보안 완화 플래그(`--disable-web-security`·
205
+ `--allow-file-access-from-files`·`--no-sandbox`)는 쓰지 않는다(R3).
206
+
207
+ **실측 (2026-07-31)** — 이 플래그가 IP 직접 지정도 막는지 논쟁이 있어 재봤다.
208
+ 로컬 HTTP 서버를 띄우고 페이지가 `http://127.0.0.1:<port>/ping` 을 부르게 한 뒤
209
+ **서버에 실제로 닿았는지**로 판정했다:
210
+
211
+ ```
212
+ 플래그 없음 페이지="HIT" 서버 도달 1건
213
+ 플래그 있음 페이지="BLOCKED:Failed to fetch" 서버 도달 0건
214
+ ```
215
+
216
+ 표적이 IP 리터럴이므로 **IP 를 직접 적어도 막힌다.** 한때 이 문서에 "이름 풀이만
217
+ 막으니 IP 는 통과한다"고 적었는데 그건 틀렸다 — 코덱스가 Chromium 소스를 근거로
218
+ 지적했고 실측으로 확인했다.
219
+
220
+ > 다만 `file:`·`data:`·`blob:` 처럼 이름 풀이를 쓰지 않는 스킴은 이 플래그의 대상이
221
+ > 아니다. 그래서 순서는 그대로다 — 2차를 믿고 1차를 느슨하게 하면 안 된다.
222
+
223
+ 크롬은 **임시 프로필**(`--user-data-dir`)로 띄운다. 지정하지 않으면 사용자의 로그인
224
+ 세션이 든 기본 프로필에서 렌더하게 된다.
225
+
226
+ ### ③ 아무 파일이나 인터넷에 올라가지 않는가
227
+
228
+ `velog_upload_image` 는 경로를 받아 **공개 CDN** 으로 보낸다. 경로를 그대로 믿으면
229
+ "이미지 좀 올려줘" 한 마디에 개인키가 올라갈 수 있다.
230
+
231
+ 그래서 **확장자가 아니라 파일 앞부분 시그니처로 판정한다.** PNG·JPEG·GIF·WebP 만
232
+ 통과하고, 그 외는 전부 막힌다. SVG 도 안 받는다 — 텍스트라 시그니처로 가릴 수 없고,
233
+ 스크립트를 품은 채 velcdn 도메인에서 서빙되면 그 자체가 문제가 된다.
234
+ 상한은 10MB 로 서버 상한(30MB)보다 낮게 잡았다. (R5 가 개인키·`/etc/passwd` 모양
235
+ 텍스트·SVG·빈 파일·한 칸 밀린 시그니처를 전부 막는지 검사한다.)
236
+
237
+ 시그니처만 보면 부족했다. **"PNG 머리 8바이트 + 아무 텍스트"** 가 통과한다.
238
+ 끝맺음도 봤지만 그것도 부족했다 — `IDAT` 을 파일 전체에서 **바이트열로** 찾았더니
239
+ `IHDR 의 payload 안에 IDAT 이라는 글자` 를 넣은 파일이 통과했다. 청크가 아니라
240
+ 글자를 본 것이다.
241
+
242
+ 지금은 **청크 구조를 실제로 걸어간다:**
243
+
244
+ ```
245
+ PNG 첫 청크가 IHDR(길이 13)인가 · 폭·높이가 0 이 아닌가
246
+ 내용이 있는 IDAT 이 있는가 · IEND 가 **파일의 끝**인가
247
+ WebP RIFF 길이가 실제 파일에 맞는가 · 청크 경계를 끝까지 따라갈 수 있는가
248
+ VP8/VP8L, 또는 프레임 헤더(16바이트)를 채운 ANMF 가 있는가
249
+ JPEG·GIF 끝맺음(FFD9 / 0x3B)만 본다 — 컨테이너가 단순하지 않아 그 이상은 디코더가 필요하다
250
+ ```
251
+
252
+ 완전한 디코딩은 아니다. 화소가 진짜인지는 안 본다. 목적은 **아무 바이트나 이미지인
253
+ 척 공개 CDN 에 올라가는 것**을 막는 것이다.
254
+
255
+ ★ 이 검사는 조일수록 **정상 파일을 거부할** 위험이 커진다. 그래서 실물로 확인한다 —
256
+ `sips`·`cwebp` 산출물 7종(IDAT 청크가 103개인 PNG 포함), 확장 WebP(VP8X+ALPH+VP8),
257
+ APNG(acTL/fcTL/fdAT), 메타데이터 선행 청크(ICCP), 홀수 길이 패딩까지 전부 통과한다.
258
+
259
+ 크기는 **실제로 읽은 바이트** 하나로만 판정한다(10MB). 예전엔 `stat` 으로 한 번 보고
260
+ `readFile` 에서 또 봤는데, `readFile` 이 내부에서 크기를 다시 재기 때문에 그 사이에
261
+ 파일이 커지면 상한을 넘겨 읽었다. 그리고 **검사가 둘이면 하나를 지워도 다른 하나가
262
+ 가려서 테스트가 못 잡는다.** 파일은 `O_NONBLOCK` 으로 연다 — 그냥 열면 FIFO 에서
263
+ writer 를 기다리며 멈춰 Node 의 파일 I/O 스레드풀이 고갈된다.
264
+
265
+ > **올라간 주소는 공개다.** 주소를 아는 사람은 누구나 볼 수 있고, 벨로그에는
266
+ > 이미지 삭제 mutation 이 없다. 되돌릴 수 없는 동작으로 취급하라.
267
+
268
+ ### ④ 어설픈 그림이 조용히 올라가지 않는가
269
+
270
+ 자가감사에 하나라도 걸리면 업로드를 건너뛰고 무엇이 걸렸는지 돌려준다.
271
+ 감사 항목을 새로 만들고 차단 조건에 넣는 걸 잊으면 결함 있는 그림이 조용히 올라가므로,
272
+ **`AuditReport` 의 모든 항목이 차단 조건에 들어 있는지**를 테스트가 강제한다(R7).
273
+ 차단이 실제로 동작하는지는 도구를 호출해 **업로드 요청이 나가는지**로 본다(R14).
274
+
275
+ **★ 한때 `force_upload` 파라미터로 이 차단을 끌 수 있었다.** 그건 이 저장소가 공개
276
+ 발행에서 이미 세운 원칙(ADR 0004)을 그대로 어긴 것이다 — 모델이 스스로 켤 수 있는
277
+ 스위치는 방어가 아니다. 없앴고, 도구 스키마에 우회로 보이는 파라미터가 생기지
278
+ 않는지도 테스트가 본다.
279
+
280
+ ## ⑤ 자원 — 남의 기계에서 도는 물건이다
281
+
282
+ 이 서버는 사용자 기기에서 돈다. 메모리를 함부로 쓰면 다른 작업까지 같이 죽는다.
283
+ 실제로 이 저장소를 만들다가 개발 기계가 메모리 부족으로 멈춘 적이 있다.
284
+
285
+ 실측:
286
+
287
+ | | 값 |
288
+ | --- | --- |
289
+ | 그림 한 장 | 크롬 9~11개 · 최대 약 1GB · 3~4초 → 끝나면 0 |
290
+ | 10회 연속 | 최대치 평평 · 매회 후 잔존 0 · 서버 RSS 61MB 안정 |
291
+ | 동시 2회(직렬화 전) | 17개 · 1.9GB — **선형 증가** |
292
+ | 동시 4회(직렬화 후) | 9개 · 1.0GB |
293
+
294
+ 막아둔 것:
295
+
296
+ - **렌더 직렬화.** MCP 클라이언트는 도구를 병렬로 부른다. 그림 다섯 장 요청이면
297
+ 크롬 45개·6GB 다. 줄을 세우면 상한이 1GB 로 고정된다. 한 장에 4초라 손해가 거의 없다.
298
+ - **캔버스 상한**(6000px / 900만px)을 **페이지 안에서** 건다. 브라우저는 `width`/`height`
299
+ 를 받는 순간 그만한 표면을 준비하므로 바깥 검사는 늦다. 페이지가 크기를 **설정하기
300
+ 전에** 스스로 거른다. 바깥 검사는 2차 방어.
301
+ - **좌표 ±20000 · 점 40개/엣지 · 글자 길이 필드별 상한 · 배열 개수 상한.**
302
+ 좌표만 묶고 글자를 열어두면 제목 하나로 HTML·SVG·`getBBox`·stdout 을 동시에 부풀린다.
303
+ 특히 `plane.dash` 는 엣지 120개의 `stroke-dasharray` 로 전부 복제된다.
304
+ - **크롬 stdout 32MB 상한**(바이트 기준, 더한 뒤 판정).
305
+ - **고아 프로세스 정리.** 서버가 죽어도 자식 크롬은 안 죽는다(POSIX). 실제로 PPID=1 로
306
+ 23분 살아 있는 걸 확인했다. exit·SIGINT·SIGTERM·SIGHUP 에서 정리한다.
307
+ - **임시 산출물 24시간 sweep.**
308
+
309
+ ## 검증
310
+
311
+ `src/__tests__/safety.test.ts` 가 소스와 실제 MCP 세션을 함께 검사한다.
312
+
313
+ | # | 항목 | 방법 |
314
+ | --- | --- | --- |
315
+ | A1 | 공개 발행은 사용자만 켤 수 있다 | 설정 off 면 `is_private` 파라미터 부재 |
316
+ | A2 | 초안 도구는 어떤 설정에서도 발행 안 함 | `DRAFT_ONLY` 상수 + 스키마 검사 |
317
+ | A3 | 금지 mutation 미구현 | 소스 호출 부재 + 도구명 검사 |
318
+ | A4 | 도구 목록 스냅샷 | 새 도구가 늘면 실패 → 의식적 갱신 |
319
+ | A5 | 토큰이 디스크로 안 나감 | 파일 쓰기 API 부재 |
320
+ | A6 | 런타임 의존성 ≤ 2 | `package.json` 검사 |
321
+ | A7 | 벨로그 외 호스트 없음 | 소스 URL 검사 |
322
+ | A8 | `editPost` 4경로 전부 글 소유권 검증 | import·호출수·구현 단일성 |
323
+ | A9 | `editPost` 가 `meta` 를 보존 | 수정 경로의 `meta:{}` 금지 |
324
+ | A10 | 프로필 수정은 게이트 필요 | 설정 off 면 도구 미등록 |
325
+ | A11 | `series_id` 소유권 검증 | **도구를 실제로 호출**해 거부 확인 |
326
+
327
+ 그림 기능은 `src/__tests__/render.test.ts` 가 따로 본다.
328
+
329
+ | # | 항목 | 방법 |
330
+ | --- | --- | --- |
331
+ | R1 | 페이지 스크립트 문법 | `node:vm` 으로 파싱 (브라우저 띄우기 전) |
332
+ | R2 | 입력이 스크립트를 탈출 못 함 | 주입 문자열로 `<script>` 개수 불변 확인 |
333
+ | R3 | 렌더 페이지가 네트워크 못 씀 | DNS 차단 플래그 존재 + 완화 플래그 부재 |
334
+ | R4 | 색은 `#rrggbb` 만 | `url()`·`var()`·앞뒤 공백 전부 거부 |
335
+ | R5 | 이미지 아닌 건 안 올라감 | 개인키·텍스트·SVG·밀린 시그니처 거부 |
336
+ | R6 | 자격증명은 벨로그 주소로만 | 다른 주소면 요청 자체 거부 |
337
+ | R7 | 감사 항목 ↔ 차단 조건 동기 | `AuditReport` 필드 전수 대조 |
338
+ | R8 | 아이콘은 내장 도형뿐 | path 문자 집합 검사 |
339
+ | R9 | 무인증이면 안 그림 | 도구 호출로 거부 확인 |
340
+ | R10 | plane key·dash·아이콘·톤 이름 | 도구 호출로 거부 확인 |
341
+ | R11 | 감사 실동작 (대각선·나란한 선·`__proto__` id) | **실렌더** 양성/음성 쌍 |
342
+ | R12 | 페이지에 URL 자리가 없다 | `href`·`src` 부재 + `url(` 은 `url(#` 뿐 |
343
+ | R13 | 서버가 죽으면 크롬도 죽는다 | 렌더 중 자식 종료 후 프로세스 확인 |
344
+ | R14 | 감사 차단이 **실제로** 업로드를 막는다 | 도구 호출로 업로드 요청 유무 확인(양성/음성) |
345
+ | R15 | 초대형 캔버스는 **찍기 전에** 막힌다 | MAX_DIM·MAX_AREA 단독 초과 각각 + 정상 크기 |
346
+ | R16 | 렌더는 한 번에 하나만 돈다 | 다이어그램·표지 섞어 동시 호출 후 프로세스 수 + 실패 후 큐 |
347
+ | R17 | 배지 경계·표지 라벨·`:side` id | 실렌더로 지목 여부까지 확인 |
348
+ | R18 | 업로드 실패가 '결과 불명'을 알린다 | 5xx·통신단절 문구 확인 |
349
+ | R19 | 모서리 라운딩이 원래 선을 안 벗어난다 | 렌더된 **d 속성**을 다시 읽어 좌표 확인 |
350
+ | R20 | 정상 형식 변형을 거부하지 않는다 | 확장 WebP·APNG·메타 선행·홀수 패딩 + 음성 4종 |
351
+ | R21 | 글자·배열 개수 상한 | **상한 바로 위** 값으로 필드별 확인 |
352
+ | R22 | `'exit'` 이 아니라 `'close'` 를 본다 | 가짜 자식 프로세스로 타이밍 재현 (벽시계 대기 없음) |
353
+ | R23 | 상한 초과 파일은 읽는 단계에서 거부 | 11MB 실파일 + 정상 크기(양성) |
354
+
355
+ R13·R14 는 코덱스 교차검증에서 나왔다. R13 은 실제로 고아 크롬을 만든 뒤 잡은 것이고
356
+ (PPID=1 로 23분 생존), R14 는 "R7 은 판정식에 필드가 있는지만 보므로 `finish()` 가
357
+ 그 판정을 무시해도 통과한다"는 지적에 대한 답이다.
358
+
359
+ R1 은 실제 사고에서 나왔다. 페이지 스크립트에 식별자 충돌(`var hit` 과
360
+ `function hit`)이 생겨 통째로 안 돌았는데, 바깥에서는 "결과를 못 읽었다"는 말밖에
361
+ 못 했다. 브라우저를 띄우기 전에 문법을 본다.
362
+
363
+ A11 은 처음에 텍스트 검사여서 강제력이 없었다 — 한 도구에만 넣어도 통과했다.
364
+ 지금은 남의 시리즈 id 를 실제로 넣어 ①거부 ②mutation 미전송 ③**거부 사유가
365
+ 시리즈 소유권인지**까지 본다. 마지막 조건이 없으면 다른 사전검사가 대신
366
+ 막아주는 걸 통과로 착각한다.
367
+
368
+ ## 사용자가 직접 확인하는 법
369
+
370
+ ```bash
371
+ # 발행 경로가 정말 없는지
372
+ grep -rn "is_temp" src/
373
+
374
+ # 벨로그 외 호스트로 나가는 요청이 있는지
375
+ grep -rnE "https?://" src/ | grep -v velog.io
376
+
377
+ # 파일을 쓰는 코드가 있는지
378
+ grep -rnE "writeFile|appendFile|createWriteStream" src/
379
+ ```
380
+
381
+ 마지막 항목은 `tools/export.ts`(글 백업)와 `render/index.ts`(그림 임시파일)만
382
+ 걸려야 한다. 둘 다 토큰이 아니라 사용자가 요청한 산출물을 쓴다 —
383
+ 그리고 **파일을 쓰는 모듈은 토큰 관련 심볼을 참조하지 않는다**는 것을 A5 가
384
+ 따로 검사한다. 예외 목록을 늘리는 대신 조건을 건 것이다.