korean-law-mcp 4.9.2 → 4.9.3

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.
@@ -21,10 +21,16 @@ export function getExternalHttpsProxyConfig() {
21
21
  const proxyAuthorization = username
22
22
  ? `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
23
23
  : undefined;
24
+ // TLS 검증 비활성화는 진단용 임시 우회다. 운영(NODE_ENV=production)에서는
25
+ // 무시하고 항상 검증한다 — 끄면 프록시 구간이 중간자 공격에 무방비가 된다.
26
+ const disableTls = process.env.LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED === "0";
27
+ if (disableTls && process.env.NODE_ENV === "production") {
28
+ console.error("⚠️ LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED=0 은 운영 환경에서 무시됩니다 (TLS 검증 유지).");
29
+ }
24
30
  return {
25
31
  host: proxyUrl.hostname,
26
32
  port: proxyUrl.port ? Number(proxyUrl.port) : 80,
27
- rejectUnauthorized: process.env.LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED !== "0",
33
+ rejectUnauthorized: !disableTls || process.env.NODE_ENV === "production",
28
34
  proxyAuthorization,
29
35
  };
30
36
  }
@@ -70,9 +70,15 @@ export async function followLawAntibot(response, originalUrl, headers, timeout,
70
70
  if (!path)
71
71
  return hopped ? current : null;
72
72
  // path는 보통 절대경로(/DRF/...). 원본 URL의 origin에 붙인다.
73
+ // path가 절대 URL이면 base가 무시되므로, 홉 대상이 원본과 같은 호스트인지
74
+ // 반드시 확인한다 — 아니면 응답 하나로 임의 호스트 요청을 유도할 수 있다.
73
75
  let nextUrl;
74
76
  try {
75
- nextUrl = new URL(path, originalUrl).toString();
77
+ const parsed = new URL(path, originalUrl);
78
+ if (parsed.hostname !== new URL(originalUrl).hostname) {
79
+ return hopped ? current : null;
80
+ }
81
+ nextUrl = parsed.toString();
76
82
  }
77
83
  catch {
78
84
  return hopped ? current : null;
@@ -6,11 +6,24 @@
6
6
  * 참고: @modelcontextprotocol/sdk/examples/server/simpleStatelessStreamableHttp.js
7
7
  */
8
8
  import express from "express";
9
+ import { timingSafeEqual } from "node:crypto";
9
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
11
  import { requestContext } from "../lib/session-state.js";
11
12
  import { maskSensitiveUrl } from "../lib/fetch-with-retry.js";
12
13
  import { TOOL_COUNTS } from "../tool-registry.js";
13
14
  import { VERSION } from "../version.js";
15
+ /** 타이밍 공격 내성 문자열 비교 (길이가 달라도 throw하지 않음) */
16
+ function safeEqual(a, b) {
17
+ const bufA = Buffer.from(a);
18
+ const bufB = Buffer.from(b);
19
+ if (bufA.length !== bufB.length)
20
+ return false;
21
+ return timingSafeEqual(bufA, bufB);
22
+ }
23
+ /** `Authorization: Bearer x` → `x` (Bearer 접두사가 없으면 원문) */
24
+ function bearerValue(raw) {
25
+ return raw ? raw.replace(/^Bearer\s+/i, "") : "";
26
+ }
14
27
  /**
15
28
  * 에러 메시지에서 민감 정보(API 키 포함 URL) scrub.
16
29
  * MCP 응답/서버 로그 양쪽에 적용되어야 함.
@@ -45,6 +58,80 @@ export async function startHTTPServer(createServer, port) {
45
58
  next();
46
59
  });
47
60
  }
61
+ // ── Origin 검증 (DNS rebinding 방어) ──────────────────────────────────────
62
+ // MCP 명세는 로컬/원격 HTTP 트랜스포트에 Origin 검증을 요구한다. 브라우저가 심은
63
+ // Origin을 그대로 통과시키면, 사용자가 악성 페이지를 여는 것만으로 그 페이지가
64
+ // 이 서버를 대신 호출하고 응답까지 읽어갈 수 있다(CORS가 '*'면 특히).
65
+ // 정책: Origin 헤더가 없는 요청(일반 MCP 클라이언트·서버간 호출)은 그대로 통과.
66
+ // Origin이 있으면 ALLOWED_ORIGINS 화이트리스트에 있어야 한다.
67
+ // CORS_ORIGIN을 명시적으로 설정한 경우 그 값도 허용 목록으로 취급한다.
68
+ const corsOriginConfigured = process.env.CORS_ORIGIN !== undefined;
69
+ const corsOrigin = process.env.CORS_ORIGIN || "*";
70
+ const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
71
+ .split(",")
72
+ .map(o => o.trim())
73
+ .filter(Boolean);
74
+ if (!corsOriginConfigured && allowedOrigins.length === 0) {
75
+ console.error("ℹ️ ALLOWED_ORIGINS 미설정 — Origin 헤더가 붙은 브라우저 요청은 차단됩니다 (DNS rebinding 방어). 웹 클라이언트를 쓰려면 ALLOWED_ORIGINS를 설정하세요.");
76
+ }
77
+ /** 이 Origin을 허용할지 판정. 반환값은 응답에 실을 ACAO 값(null이면 거부) */
78
+ function resolveOrigin(origin) {
79
+ if (!origin)
80
+ return corsOrigin; // 브라우저가 아닌 클라이언트
81
+ if (allowedOrigins.includes(origin))
82
+ return origin;
83
+ if (allowedOrigins.length === 0 && corsOriginConfigured) {
84
+ if (corsOrigin === "*")
85
+ return "*";
86
+ if (corsOrigin === origin)
87
+ return origin;
88
+ }
89
+ return null;
90
+ }
91
+ app.use((req, res, next) => {
92
+ if (req.path === "/health" || req.path === "/")
93
+ return next();
94
+ const origin = req.headers.origin;
95
+ const allowed = resolveOrigin(origin);
96
+ if (allowed === null) {
97
+ res.status(403).json({
98
+ jsonrpc: "2.0",
99
+ error: { code: -32000, message: "Origin not allowed." },
100
+ id: null,
101
+ });
102
+ return;
103
+ }
104
+ res.header("Access-Control-Allow-Origin", allowed);
105
+ if (allowed !== "*")
106
+ res.header("Vary", "Origin");
107
+ next();
108
+ });
109
+ // ── 접근 인증 (MCP_AUTH_TOKEN 설정 시에만 활성) ────────────────────────────
110
+ // 폐쇄망·사내망 배포처럼 서버 자체에 접근 통제가 필요한 환경에서 설정한다.
111
+ // 미설정이면 기존처럼 공개 동작 (법제처 API 키는 인가 수단이 아니다).
112
+ const authToken = process.env.MCP_AUTH_TOKEN || "";
113
+ if (!authToken) {
114
+ console.error("⚠️ MCP_AUTH_TOKEN 미설정 — /mcp 엔드포인트에 접근 인증이 없습니다. 폐쇄망/사내 배포에서는 반드시 설정하세요.");
115
+ }
116
+ app.use((req, res, next) => {
117
+ if (!authToken)
118
+ return next();
119
+ if (req.method === "OPTIONS")
120
+ return next(); // 프리플라이트는 인증 헤더를 못 싣는다
121
+ if (req.path === "/health" || req.path === "/")
122
+ return next();
123
+ const presented = req.headers["x-mcp-token"] ||
124
+ bearerValue(req.headers["authorization"]);
125
+ if (!presented || !safeEqual(presented, authToken)) {
126
+ res.status(401).json({
127
+ jsonrpc: "2.0",
128
+ error: { code: -32001, message: "Unauthorized." },
129
+ id: null,
130
+ });
131
+ return;
132
+ }
133
+ next();
134
+ });
48
135
  app.use(express.json({ limit: process.env.MCP_BODY_LIMIT || "100kb" }));
49
136
  // Rate Limiting (RATE_LIMIT_RPM 환경변수, 기본: 60 req/min per IP)
50
137
  const rateLimitRpm = parseInt(process.env.RATE_LIMIT_RPM || "60", 10);
@@ -93,15 +180,10 @@ export async function startHTTPServer(createServer, port) {
93
180
  }
94
181
  }, 5 * 60 * 1000).unref();
95
182
  }
96
- // CORS 및 보안 헤더 설정 (CORS_ORIGIN 미설정 시 경고)
97
- const corsOrigin = process.env.CORS_ORIGIN || "*";
98
- if (corsOrigin === "*") {
99
- console.error("⚠️ CORS_ORIGIN 미설정 — 모든 도메인 허용 중. 프로덕션에서는 CORS_ORIGIN 환경변수를 설정하세요.");
100
- }
183
+ // 보안 헤더 (Access-Control-Allow-Origin은 위 Origin 검증 미들웨어가 설정)
101
184
  app.use((req, res, next) => {
102
- res.header("Access-Control-Allow-Origin", corsOrigin);
103
185
  res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
104
- res.header("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, last-event-id");
186
+ res.header("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, last-event-id, apikey, x-api-key, x-mcp-token, authorization");
105
187
  res.header("X-Content-Type-Options", "nosniff");
106
188
  res.header("X-Frame-Options", "DENY");
107
189
  res.header("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -152,14 +234,19 @@ export async function startHTTPServer(createServer, port) {
152
234
  // POST /mcp - stateless 요청 처리
153
235
  app.post("/mcp", async (req, res) => {
154
236
  // Extract API key: header > URL query
155
- // 쿼리스트링 키는 프록시/엣지 액세스 로그에 평문으로 남으므로 헤더 사용 권장 (하위호환용 유지)
237
+ // 쿼리스트링 키는 프록시/엣지 액세스 로그에 평문으로 남으므로 헤더 사용 권장.
238
+ // ALLOW_QUERY_API_KEY=0 으로 쿼리 경로를 차단할 수 있다 (폐쇄망 권장).
239
+ // 인증이 켜진 경우 Authorization 헤더는 접근 토큰이므로 법제처 키로 오인하면 안 된다.
240
+ const authHeader = bearerValue(req.headers["authorization"]);
241
+ const authHeaderIsAccessToken = Boolean(authToken) && authHeader && safeEqual(authHeader, authToken);
242
+ const queryKey = process.env.ALLOW_QUERY_API_KEY === "0" ? undefined : req.query.oc;
156
243
  const apiKey = req.headers["apikey"] ||
157
244
  req.headers["law_oc"] ||
158
245
  req.headers["law-oc"] ||
159
246
  req.headers["x-api-key"] ||
160
- req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
247
+ (authHeaderIsAccessToken ? undefined : authHeader || undefined) ||
161
248
  req.headers["x-law-oc"] ||
162
- req.query.oc;
249
+ queryKey;
163
250
  // 자체 키 없는 요청은 서버 LAW_OC로 폴백 — 전역 상한 적용
164
251
  // initialize/tools/list 등 핸드셰이크는 법제처 쿼터를 안 쓰므로 tools/call만 게이트
165
252
  // (핸드셰이크까지 429로 막으면 claude.ai 커넥터가 도구 목록 자체를 못 싣는다)
@@ -230,6 +317,23 @@ export async function startHTTPServer(createServer, port) {
230
317
  id: null
231
318
  });
232
319
  });
320
+ // 최종 에러 핸들러 — Express 기본 핸들러는 스택 트레이스와 설치 경로를 그대로
321
+ // 본문에 실어 보낸다(NODE_ENV 미설정 시). body-parser의 413/400도 여기로 온다.
322
+ app.use((err, _req, res, _next) => {
323
+ const status = typeof err?.status === "number" ? err.status : 500;
324
+ const scrubbed = scrubError(err);
325
+ console.error(`[express] ${status} ${scrubbed.message}`);
326
+ if (res.headersSent)
327
+ return;
328
+ res.status(status).json({
329
+ jsonrpc: "2.0",
330
+ error: {
331
+ code: status === 413 ? -32600 : -32603,
332
+ message: status === 413 ? "Request entity too large." : "Internal server error",
333
+ },
334
+ id: null,
335
+ });
336
+ });
233
337
  // 서버 시작 (0.0.0.0으로 바인딩하여 외부 접속 허용)
234
338
  const expressServer = app.listen(port, "0.0.0.0", () => {
235
339
  console.error(`✓ Korean Law MCP server (HTTP stateless) listening on port ${port}`);
package/build/setup.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * API 키를 입력받고, 선택한 AI 클라이언트 설정 파일에 MCP 서버를 자동 등록합니다.
6
6
  */
7
7
  import { createInterface } from "node:readline/promises";
8
- import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
9
9
  import { existsSync } from "node:fs";
10
10
  import { resolve, dirname } from "node:path";
11
11
  import { homedir, platform } from "node:os";
@@ -87,7 +87,13 @@ async function writeJsonFile(path, data) {
87
87
  if (!existsSync(dir)) {
88
88
  await mkdir(dir, { recursive: true });
89
89
  }
90
- await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
90
+ // 설정 파일에는 LAW_OC가 평문으로 들어간다 → 소유자만 읽도록 제한.
91
+ // mode 옵션은 새로 만들 때만 먹으므로, 기존 파일에는 chmod로 다시 적용한다.
92
+ await writeFile(path, JSON.stringify(data, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
93
+ try {
94
+ await chmod(path, 0o600);
95
+ }
96
+ catch { /* 권한 변경 실패는 설치를 막지 않는다 (Windows 등) */ }
91
97
  }
92
98
  function buildServerEntry(apiKey, lawApiProtocol = getLawApiProtocol()) {
93
99
  const env = {};
@@ -261,10 +261,17 @@ async function fetchManualRedirect(url, proxyConfig) {
261
261
  location: response.headers.get("location"),
262
262
  };
263
263
  }
264
+ /** 판례 본문 fallback 경로에서 따라가도 되는 호스트 (법제처·국세청만) */
265
+ function isAllowedFallbackHost(hostname) {
266
+ return /(^|\.)law\.go\.kr$/i.test(hostname) || /(^|\.)nts\.go\.kr$/i.test(hostname);
267
+ }
264
268
  async function resolveTaxlawDetailUrl(iframeUrl) {
265
269
  let currentUrl = iframeUrl;
266
270
  const proxyConfig = getExternalHttpsProxyConfig();
267
271
  for (let redirectCount = 0; redirectCount < 3; redirectCount++) {
272
+ if (!isAllowedFallbackHost(new URL(currentUrl).hostname)) {
273
+ throw new Error("precedent fallback redirect target is not an allowed host");
274
+ }
268
275
  const iframeResponse = await fetchManualRedirect(currentUrl, proxyConfig);
269
276
  const location = iframeResponse.location;
270
277
  if (!location) {
@@ -272,6 +279,9 @@ async function resolveTaxlawDetailUrl(iframeUrl) {
272
279
  }
273
280
  const nextUrl = normalizeUrl(location, currentUrl);
274
281
  const parsedNextUrl = new URL(nextUrl);
282
+ if (!isAllowedFallbackHost(parsedNextUrl.hostname)) {
283
+ throw new Error("precedent fallback redirect target is not an allowed host");
284
+ }
275
285
  if (parsedNextUrl.searchParams.get("ntstDcmId")) {
276
286
  return nextUrl;
277
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korean-law-mcp",
3
- "version": "4.9.2",
3
+ "version": "4.9.3",
4
4
  "description": "법제처 42개 API → 9개 MCP 도구. 법령·판례·조례·조약 + 다단계 리서치(legal_research, 8 task) + 정밀분석(legal_analysis: 인용검증·판례생사·행위시법·영향그래프) + 시점 비교(time_travel) + 상황별 5단계 안내(action_plan) + 국세청 해석례(nts)",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -60,7 +60,7 @@
60
60
  "commander": "^14.0.3",
61
61
  "dotenv": "^17.3.1",
62
62
  "express": "^5.2.1",
63
- "kordoc": "^4.2.0",
63
+ "kordoc": "^4.5.0",
64
64
  "pdfjs-dist": "^5.5.207",
65
65
  "zod": "^4.0.0"
66
66
  },
@@ -73,7 +73,10 @@
73
73
  "vitest": "^4.1.9"
74
74
  },
75
75
  "overrides": {
76
- "hono": "^4.12.29"
76
+ "hono": "^4.12.29",
77
+ "sharp": "^0.35.3",
78
+ "adm-zip": "^0.6.0",
79
+ "onnxruntime-node": "^1.27.0"
77
80
  },
78
81
  "files": [
79
82
  "build",