@zalkera/client 0.22.4 → 0.23.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  - 대상: 잘커라를 데이터로 쓰는 **테넌트 사이트**(쇼핑몰·예약·콘텐츠).
6
6
  - 성격: 얇은 `fetch` 래퍼 — **런타임 의존성 0**, ESM·CJS + 타입 선언 동봉.
7
- - 동작: 응답 껍데기(`ApiResponse<T>`)를 벗겨 `data`만 반환, 실패는 `ZalkeraError`로 throw.
7
+ - 동작: 응답 껍데기(`ApiResponse<T>`)를 벗겨 `data`만 반환, **API 호출** 실패는 `ZalkeraError`로 throw.
8
8
  - 전제: **서버 사이드 전용**(RSC·route handler·server action). 브라우저 직접 호출 미지원 ([서버 사이드 전용](#서버-사이드-전용-중요) 참고).
9
9
 
10
10
  ## 설치
@@ -178,6 +178,21 @@ npx zalkera-validate ./src --gate # 관문 모드 — 우리가 서빙할 때
178
178
  종료코드는 넷이다: `0` 통과 · `1` 위반 · `2` 인자·전제 오류 · `7` 판정 불능. `--gate` 는 **못 잰 자리**가 있으면
179
179
  `7` 을 낸다 — 통과가 아니라 "판정 불능"이라는 뜻이다.
180
180
 
181
+ ### `typescript` 가 있어야 돈다
182
+
183
+ 들여오기 판정(E1·E2·C1)은 **검사 대상 레포의 TypeScript** 로 소스를 읽는다. 이 패키지는 의존이 0이고,
184
+ 파서를 손으로 근사하지 않는다 — 그 근사가 우회와 오탐을 번갈아 낳는 것을 여러 판에 걸쳐 겪었다.
185
+
186
+ 우리 시작 소스 팩은 `typescript` 를 devDependency 로 이미 문다. 직접 꾸린 레포(BYO)이거나 TypeScript 를
187
+ 안 쓰는 JS 전용 레포라면 **깔아야 한다**:
188
+
189
+ ```bash
190
+ npm i -D typescript
191
+ ```
192
+
193
+ 없으면 검사기는 **통과로 넘기지 않고** 그 파일들을 「못 잰 자리」로 적는다 — `--gate` 에서 `7` 이다.
194
+ "못 읽었다"와 "위반이 없다"는 다른 사실이고, 둘을 같은 출력으로 내면 관문이 조용히 눈이 먼다.
195
+
181
196
  **모드가 severity 를 바꾼다.** `package.json` 의 `zalkera.styling`·`zalkera.content` 를 선언하면
182
197
  그 축이 경고에서 오류로 올라간다(선언한 계약을 지키는지 보는 것이다). 어느 축이 무엇인지는
183
198
  검사기가 첫 줄에 스스로 찍는다:
@@ -230,7 +245,10 @@ await zalkera.submitInquiry(input, {clientIp: ip});
230
245
 
231
246
  ## 에러 처리 (`ZalkeraError`)
232
247
 
233
- - 실패는 전부 `ZalkeraError` throw. **원인 분기는 `code`로** (같은 status에 여러 원인 — `message`는 사람용 한국어, 문자열 매칭 금지).
248
+ - **API 호출** 실패는 전부 `ZalkeraError` throw. **원인 분기는 `code`로** (같은 status에 여러 원인 — `message`는 사람용 한국어, 문자열 매칭 금지).
249
+ - ⚠ **배선 오류는 `ZalkeraError` 가 아니다.** `baseUrl` 이 비었거나 절대 URL 이 아니면, 그리고 전역
250
+ `fetch` 가 없으면 `createZalkeraClient()` 가 그 자리에서 평범한 `Error` 로 죽는다(메시지가 환경변수
251
+ 이름을 짚는다). `instanceof ZalkeraError` 로만 받는 `catch` 는 이걸 통과시킨다.
234
252
 
235
253
  | 멤버 | 설명 |
236
254
  |---|---|
@@ -259,13 +277,14 @@ try {
259
277
 
260
278
  ### SDK 가 직접 만드는 에러 코드
261
279
 
262
- 백엔드 `ErrorCode`가 아니라 클라이언트가 붙이는 코드다. 전부 `status === 502`이고 **배선·상류 문제**라 재시도로 풀리지 않는다.
280
+ 백엔드 `ErrorCode`가 아니라 클라이언트가 붙이는 코드다. **배선·상류 문제**라 재시도로 풀리지 않는다. `status`는 코드마다 다르다 — `e.status === 502`로 분기하면 아래 **넷 둘**을 놓친다(`UPSTREAM_UNAVAILABLE`의 503·504와 `INVALID_PATH`의 400).
263
281
 
264
- | `code` | 뜻 / 처방 |
265
- |---|---|
266
- | `UPSTREAM_REDIRECT` | 백엔드가 3xx로 응답했다. **클라이언트는 리다이렉트를 따라가지 않는다** — 따라가면 `X-Storefront-Key`·`X-Tenant`·`X-Cart-Session`이 제3자 오리진에 그대로 전달되기 때문이다. `baseUrl`이 **최종 오리진**인지 확인하라(http↔https 승격·www 유무·프록시 경로 리라이트). 우회 옵션은 없다(보안 불변식). |
267
- | `UPSTREAM_NON_JSON` | 2xx인데 본문이 envelope가 아니다(게이트웨이 HTML 등). |
268
- | `UPSTREAM_UNAVAILABLE` | 502·503·504에 비JSON 본문. 상류 장애. |
282
+ | `code` | `status` | 뜻 / 처방 |
283
+ |---|---|---|
284
+ | `UPSTREAM_REDIRECT` | 502 | 백엔드가 3xx로 응답했다. **클라이언트는 리다이렉트를 따라가지 않는다** — 따라가면 `X-Storefront-Key`·`X-Tenant`·`X-Cart-Session`이 제3자 오리진에 그대로 전달되기 때문이다. `baseUrl`이 **최종 오리진**인지 확인하라(http↔https 승격·www 유무·프록시 경로 리라이트). 우회 옵션은 없다(보안 불변식). |
285
+ | `UPSTREAM_NON_JSON` | 502 | 2xx인데 본문이 envelope가 아니다(게이트웨이 HTML 등). |
286
+ | `UPSTREAM_UNAVAILABLE` | **상류 그대로**(502·503·504) | 그 status에 비JSON 본문. 상류 장애. |
287
+ | `INVALID_PATH` | **400** | 경로 파라미터에 `.`·`..` 세그먼트가 있다. 전송 직전에 거부하므로 요청이 나가지 않는다. 슬러그·주문번호를 정규화하라. |
269
288
 
270
289
  ### 경로 파라미터
271
290
 
@@ -102,7 +102,126 @@ const die = (msg) => {
102
102
  process.exit(2);
103
103
  };
104
104
 
105
- const siteUrl = argv.find((a) => !a.startsWith("--") && /^https?:\/\//.test(a));
105
+ /**
106
+ * 검사할 사이트 주소. **위치 인자에서만** 받는다.
107
+ *
108
+ * ⚠ 종전에는 `argv` 전체를 훑어 `http(s)://` 로 시작하는 **아무 값**이나 골랐다. 그러면
109
+ * `--code http://…` 처럼 **플래그의 값**이 주소로 승격돼, 래퍼가 테넌트 문자열을 어느 플래그에든
110
+ * 끼우면 「운영자 실수」가 **테넌트가 촉발하는 SSRF** 가 된다. 플래그 다음 값은 건너뛴다.
111
+ *
112
+ * ⚠ **`--out`·`--guarantees` 는 운영자 경로다.** 둘은 파일 경로를 받아 각각 쓰고(`mkdirSync`+
113
+ * `writeFileSync`) 읽는데, 이 자리에는 봉쇄가 없다 — 위와 같은 위협모형(래퍼가 테넌트 문자열을
114
+ * 플래그에 끼운다)이 여기서는 임의 경로 쓰기·읽기가 된다. 실피해가 없는 이유는 **호출처가 한
115
+ * 곳이고 거기서 소독하기 때문**이다 — `serving-orchestrator/server.mjs` 가 `--out` 을 프로그램으로
116
+ * 넘기되(테넌트 키 파생) 바로 앞줄에서 `[^A-Za-z0-9._-]→_` 로 걸러 슬래시가 못 들어간다.
117
+ * **그 소독이 이 자리의 유일한 방벽이다.** 테넌트가 정한 문자열을 소독 없이 이 두 플래그에
118
+ * 넣지 마라 — 넣어야 한다면 여기 봉쇄를 먼저 세워라.
119
+ */
120
+ const VALUE_FLAGS = new Set(["--category", "--code", "--route", "--max-pages", "--out", "--guarantees"]);
121
+
122
+ function positionalUrl(args) {
123
+ for (let i = 0; i < args.length; i += 1) {
124
+ const a = args[i];
125
+ if (a.startsWith("--")) {
126
+ // `--flag=value` 는 한 토큰이고, 값을 받는 플래그만 다음 토큰을 삼킨다.
127
+ // 불리언 플래그(`--site-wide-only` 등) 뒤의 위치 인자를 잡아먹으면 주소를 못 찾는다.
128
+ if (!a.includes("=") && VALUE_FLAGS.has(a)) i += 1;
129
+ continue;
130
+ }
131
+ if (/^https?:\/\//.test(a)) return a;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ /**
137
+ * 검사 대상이 **공개 인터넷 호스트**인가.
138
+ *
139
+ * ⚠ 이 도구는 주소를 받아 그 사이트를 **여러 번 부른다**(robots·sitemap·페이지 크롤). 주소가
140
+ * 루프백·사설망·링크로컬·메타데이터 서비스를 가리키면 그것은 곧 **SSRF 스캐너**다. 발행 직후
141
+ * 자동 검사(`--site-wide-only`)로도 닿는 경로라, 주소를 정하는 쪽이 곧 표적을 정한다.
142
+ *
143
+ * 이름을 해석까지 하지는 않는다(DNS 재바인딩은 이 층에서 못 막는다) — 문면으로 명백한 것을
144
+ * 거절하고, 그 한계를 여기 적어 둔다. 로컬 개발은 `ZALKERA_AEO_ALLOW_LOCAL=1` 로 연다.
145
+ */
146
+ /**
147
+ * 이 호스트가 내부를 가리키는가. IPv4 표기와 IPv6 내장 주소가 **같은 문**을 지난다.
148
+ *
149
+ * 10진·8진·16진 축약형(`2130706433`·`0177.0.0.1`·`0x7f000001`)도 같은 자리를 가리키므로 함께 본다.
150
+ */
151
+ function isInternalHost(host) {
152
+ return (
153
+ host === "localhost" ||
154
+ host.endsWith(".localhost") ||
155
+ host === "::1" ||
156
+ host === "::" || // 미지정 주소 — 부르면 루프백으로 간다
157
+ host === "0.0.0.0" ||
158
+ /^127\./.test(host) ||
159
+ /^10\./.test(host) ||
160
+ /^192\.168\./.test(host) ||
161
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
162
+ /^169\.254\./.test(host) ||
163
+ // RFC 6598 CGNAT(100.64.0.0/10). 사설도 루프백도 아니지만 **우리 쪽에서 라우팅되는** 대역이라
164
+ // 열거에서 빠져 있었다. 이 열거는 스스로 이름을 해석하지 않는다고 적어 뒀으므로(아래 KDoc)
165
+ // 이 한 줄이 태세를 바꾸지는 않는다 — 열거의 결손을 메우는 것이다.
166
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
167
+ /^(fc|fd)[0-9a-f]{2}:/.test(host) ||
168
+ /^fe80:/.test(host) ||
169
+ /^\d+$/.test(host) ||
170
+ /^0[0-7]/.test(host) ||
171
+ /^0x/.test(host)
172
+ );
173
+ }
174
+
175
+ /**
176
+ * IPv6 리터럴에 든 IPv4 를 점표기로 꺼낸다. 없으면 `null`.
177
+ *
178
+ * 두 형태를 본다 — IPv4-매핑(`::ffff:…`)과 NAT64(`64:ff9b::…`). 둘 다 마지막 32비트가 IPv4 이고,
179
+ * `WHATWG URL` 은 그것을 16진 두 묶음으로 정규화한다(`::ffff:7f00:1`). 점표기로 오는 경우도
180
+ * 있으므로(정규화를 안 거친 입력) 그쪽도 받는다.
181
+ *
182
+ * ⚠ **못 보는 것**: DNS 재바인딩(공개 이름이 내부 A 레코드를 주는 경우)은 이 층에서 못 막는다 —
183
+ * 그건 이름 해석 시점의 문제라 주소 문자열로는 안 보인다. 아래 `assertPublicHost` 가 그 사실을
184
+ * 이미 적어 두었다.
185
+ */
186
+ function embeddedIPv4(host) {
187
+ const dotted = /^(?:::ffff:|64:ff9b::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
188
+ if (dotted) return dotted[1];
189
+ const hex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
190
+ if (!hex) return null;
191
+ const high = Number.parseInt(hex[1], 16);
192
+ const low = Number.parseInt(hex[2], 16);
193
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
194
+ }
195
+
196
+ function assertPublicHost(value) {
197
+ if (process.env.ZALKERA_AEO_ALLOW_LOCAL === "1") return;
198
+ let host;
199
+ try {
200
+ host = new URL(value).hostname.toLowerCase().replace(/^\[|\]$/g, "");
201
+ } catch {
202
+ die(`주소를 이해하지 못했습니다: ${value}`);
203
+ return;
204
+ }
205
+ const blocked =
206
+ isInternalHost(host) ||
207
+ // ⚠ **IPv6 안에 든 IPv4 를 꺼내 같은 판정에 태운다.** `http://[::ffff:169.254.169.254]/` 는
208
+ // 루프백·링크로컬로 라우팅되는데, 위 규칙은 점표기 IPv4 만 본다. 실측: `[::ffff:127.0.0.1]`
209
+ // 로 부르면 127.0.0.1 의 리스너가 응답한다.
210
+ //
211
+ // 형태를 하나 더 열거하는 대신 **주소를 꺼내 같은 문을 지나게** 한다 — 나중에 사설 대역을
212
+ // 하나 더 막으면 IPv6 경유도 자동으로 막힌다. `WHATWG URL` 이 표기를 정규화해 주므로
213
+ // (`[0:0:0:0:0:ffff:7f00:1]` → `::ffff:7f00:1`) 축약형 열거도 필요 없다.
214
+ isInternalHost(embeddedIPv4(host) ?? "");
215
+ if (blocked) {
216
+ die(
217
+ `내부 주소는 검사하지 않습니다: ${host}\n` +
218
+ ` 이 도구는 대상 사이트를 여러 번 부릅니다 — 내부망을 가리키면 스캐너가 됩니다.\n` +
219
+ ` 로컬 개발이라면 ZALKERA_AEO_ALLOW_LOCAL=1 을 주십시오.`,
220
+ );
221
+ }
222
+ }
223
+
224
+ const siteUrl = positionalUrl(argv);
106
225
  const categories = flagAll("category");
107
226
  const printOnly = argv.includes("--print-guarantees");
108
227
  /**
@@ -253,6 +372,7 @@ for (const c of categories) {
253
372
  }
254
373
  }
255
374
 
375
+ assertPublicHost(siteUrl);
256
376
  const origin = new URL(siteUrl).origin;
257
377
  const classify = makeClassifier({origin, skipRoutes: new Set()}); // 검사기는 아무것도 공개하지 않는다 — /policies 도 훑는다
258
378
 
@@ -399,7 +519,21 @@ const literalRoutes = Object.values(routeDefs)
399
519
  /** 기계용 파일은 크롤러가 페이지로 안 받는다(blocked) — 따로 두드린다. */
400
520
  async function fetchText(path) {
401
521
  try {
402
- const res = await fetch(origin + path, {headers: {"user-agent": "zalkera-aeo-check"}});
522
+ // **리다이렉트를 따라가지 않는다 형제 `crawlPages` 같은 불변식이다.**
523
+ // 기본값 `follow` 면 크롤 대상이 3xx 로 아무 호스트나 지목해 이 프로세스가 그리로 GET 을
524
+ // 쏜다. 이 도구는 발행 직후 자동 검사로 **우리 네트워크 위치에서** 돌 수 있으므로 그때
525
+ // 지목되는 곳은 사설대역·링크로컬(169.254.169.254)일 수 있다.
526
+ //
527
+ // 재심의가 여기를 실증했다: `/robots.txt` 가 302 로 내부 서비스를 가리키자 그 본문을
528
+ // 회수했다. 앞 판이 `crawlPages` 만 고치고 **이 세 번째 전송로**를 놓쳤다 —
529
+ // "두 전송로 중 하나에만 서 있던 것"이라고 적었으나 실제로는 셋이었다.
530
+ const res = await fetch(origin + path, {
531
+ headers: {"user-agent": "zalkera-aeo-check"},
532
+ redirect: "manual",
533
+ });
534
+ // `redirect: "manual"` 의 결과 형상은 런타임마다 다르다 — Node/undici 는 3xx 를 그대로 주고,
535
+ // 명세 준수 런타임은 `status` 0 · `type` `"opaqueredirect"` 로 준다. 둘 다 없는 것으로 친다.
536
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) return null;
403
537
  return res.ok ? await res.text() : null;
404
538
  } catch {
405
539
  return null;
@@ -415,11 +549,14 @@ if (carriedDrift) console.warn(`⚠️ ${carriedDrift}`);
415
549
  const robotsTxt = await fetchText("/robots.txt");
416
550
  const sitemapXml = await fetchText("/sitemap.xml");
417
551
  const sitemapLocs = new Set(
418
- [...(sitemapXml ?? "").matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => {
552
+ // **공백을 정규식으로 다듬지 않는다.** `\s*([^<]+?)\s*` 는 공백이 `[^<]` 안에도 들어가
553
+ // 분할점이 3중으로 모호해져 **3차**로 폭주한다(실측: 공백 2,000자에 2.1초 — 25KB 면 시간 단위).
554
+ // 입력은 **테넌트 사이트가 준 바이트**다. 게으른 수량자를 걷고 `trim()` 으로 다듬는다.
555
+ [...(sitemapXml ?? "").matchAll(/<loc>([^<]*)<\/loc>/gi)].map((m) => {
419
556
  try {
420
- return new URL(m[1]).pathname.replace(/\/+$/, "") || "/";
557
+ return new URL((m[1] ?? "").trim()).pathname.replace(/\/+$/, "") || "/";
421
558
  } catch {
422
- return m[1];
559
+ return (m[1] ?? "").trim();
423
560
  }
424
561
  }),
425
562
  );