mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Harden NTS validate privacy boundary
Prevent proxy exception messages from exposing upstream URLs, align validate field bounds across proxy and Python helpers, and make the hosted validate privacy path explicit in docs. Constraint: non-interactive PR #243 follow-up with no production DATA_GO_KR_API_KEY authority. Rejected: returning raw upstream fetch errors | could leak serviceKey if custom fetch/proxy errors include full URLs. Rejected: leaving helper-copy drift to manual cmp checks | behavior test now loads the skill-local helper directly. Confidence: high Scope-risk: narrow Directive: keep validate uncached and avoid echoing representative/date/address inputs in proxy responses. Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_nts_business_registration; npm run test --workspace k-skill-proxy -- --test-name-pattern 'NTS business'; mocked fetch-exception smoke; git diff --check origin/dev...HEAD; npm run ci Not-tested: live data.go.kr calls, no production DATA_GO_KR_API_KEY authority
This commit is contained in:
parent
521524edeb
commit
641d96b8fc
8 changed files with 203 additions and 19 deletions
|
|
@ -13,6 +13,12 @@
|
|||
|
||||
self-host 프록시를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL`을 설정한다. 비우면 hosted proxy(`https://k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
## 진위확인 개인정보 경로
|
||||
|
||||
`/v1/nts-business/validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 선택 주소/상호 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다. proxy는 validate 성공 응답을 캐시하지 않고(`status` 조회만 성공 캐시), 응답에 normalized `query`를 echo하지 않으며, upstream 응답이 요청값을 되돌려도 민감 필드를 제거한다.
|
||||
|
||||
기본 proxy 서버는 Fastify request logging을 켜지 않는다. self-host 운영자가 별도 요청 로깅을 활성화했다면 validate 요청 본문이 저장되지 않도록 로그 정책을 확인해야 한다. hosted proxy 대신 자체 운영 경로가 필요하면 `KSKILL_PROXY_BASE_URL`로 self-host proxy를 지정한다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
|
|
@ -31,6 +37,7 @@ python3 nts-business-registration/scripts/nts_business_registration.py validate
|
|||
- 상태조회/진위확인은 한 번에 최대 100건까지 보낸다.
|
||||
- 진위확인은 `b_no`, `start_dt`, `p_nm`이 필수다.
|
||||
- 선택 필드: `p_nm2`, `b_nm`, `corp_no`, `b_sector`, `b_type`, `b_adr`
|
||||
- 길이 제한: `p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자. `corp_no`는 제공 시 숫자 13자리여야 한다.
|
||||
|
||||
## 실패 모드
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ metadata:
|
|||
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `국세청_사업자등록정보 진위확인 및 상태조회 서비스` 활용신청이 되어 있어야 한다.
|
||||
|
||||
## Validate privacy boundary
|
||||
|
||||
- `validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 주소·상호 같은 선택 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다.
|
||||
- hosted proxy는 `validate` 성공 응답을 캐시하지 않고, 프록시 `query` echo를 붙이지 않으며, upstream이 요청값을 되돌려도 민감 입력 필드를 응답에서 제거한다.
|
||||
- 프록시의 기본 Fastify request logging은 꺼져 있다. 운영자가 별도 로그를 켠 self-host 환경에서는 요청 본문 로깅 정책을 직접 점검해야 한다.
|
||||
- hosted proxy 경유가 부담스러운 진위확인 업무는 `KSKILL_PROXY_BASE_URL`로 직접 운영하는 self-host proxy를 지정한다.
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 공공데이터포털 문서: `https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15081808`
|
||||
|
|
@ -68,6 +75,8 @@ metadata:
|
|||
- `b_type`: 주종목명
|
||||
- `b_adr`: 사업장주소
|
||||
|
||||
텍스트 필드는 NTS 입력 규격에 맞춰 보수적으로 길이를 제한한다(`p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자). `corp_no`는 제공할 경우 숫자 13자리여야 한다.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. 사용자 입력에서 사업자등록번호는 숫자 10자리인지 확인한다.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ from typing import Any
|
|||
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
|
||||
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
||||
BATCH_LIMIT = 100
|
||||
VALIDATE_TEXT_FIELD_LIMITS = {
|
||||
"p_nm": 30,
|
||||
"p_nm2": 30,
|
||||
"b_nm": 200,
|
||||
"b_sector": 100,
|
||||
"b_type": 100,
|
||||
"b_adr": 500,
|
||||
}
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
|
|
@ -63,6 +71,28 @@ def normalize_start_date(value: Any) -> str:
|
|||
return normalized
|
||||
|
||||
|
||||
def normalize_validate_text(value: Any, field_name: str, *, required: bool = False) -> str | None:
|
||||
text = _text_or_none(value)
|
||||
if not text:
|
||||
if required:
|
||||
raise ValueError(f"{field_name}을(를) 입력하세요.")
|
||||
return None
|
||||
max_length = VALIDATE_TEXT_FIELD_LIMITS.get(field_name)
|
||||
if max_length and len(text) > max_length:
|
||||
raise ValueError(f"{field_name}은(는) {max_length}자 이하여야 합니다.")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_corp_no(value: Any) -> str | None:
|
||||
raw = _text_or_none(value)
|
||||
if not raw:
|
||||
return None
|
||||
normalized = re.sub(r"\D", "", raw)
|
||||
if not re.fullmatch(r"\d{13}", normalized):
|
||||
raise ValueError("corp_no는 숫자 13자리여야 합니다.")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
|
||||
numbers = [normalize_business_number(value) for value in business_numbers]
|
||||
numbers = list(dict.fromkeys(numbers))
|
||||
|
|
@ -74,9 +104,7 @@ def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
|
|||
|
||||
|
||||
def build_validate_business(**kwargs: Any) -> dict[str, str]:
|
||||
p_nm = _text_or_none(kwargs.get("p_nm"))
|
||||
if not p_nm:
|
||||
raise ValueError("대표자 성명(p_nm)을 입력하세요.")
|
||||
p_nm = normalize_validate_text(kwargs.get("p_nm"), "p_nm", required=True)
|
||||
|
||||
business = {
|
||||
"b_no": normalize_business_number(kwargs.get("b_no")),
|
||||
|
|
@ -85,13 +113,13 @@ def build_validate_business(**kwargs: Any) -> dict[str, str]:
|
|||
}
|
||||
|
||||
for key in ("p_nm2", "b_nm", "b_sector", "b_type", "b_adr"):
|
||||
value = _text_or_none(kwargs.get(key))
|
||||
value = normalize_validate_text(kwargs.get(key), key)
|
||||
if value:
|
||||
business[key] = value
|
||||
|
||||
corp_no = _text_or_none(kwargs.get("corp_no"))
|
||||
corp_no = normalize_corp_no(kwargs.get("corp_no"))
|
||||
if corp_no:
|
||||
business["corp_no"] = re.sub(r"\D", "", corp_no)
|
||||
business["corp_no"] = corp_no
|
||||
return business
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ const NTS_BUSINESSMAN_UPSTREAM_BASE_URL = "https://api.odcloud.kr/api/nts-busine
|
|||
const NTS_BATCH_LIMIT = 100;
|
||||
const NTS_BUSINESS_OPERATIONS = new Set(["status", "validate"]);
|
||||
const NTS_VALIDATE_OPTIONAL_TEXT_FIELDS = ["p_nm2", "b_nm", "b_sector", "b_type", "b_adr"];
|
||||
const NTS_VALIDATE_TEXT_FIELD_LIMITS = {
|
||||
p_nm: 30,
|
||||
p_nm2: 30,
|
||||
b_nm: 200,
|
||||
b_sector: 100,
|
||||
b_type: 100,
|
||||
b_adr: 500
|
||||
};
|
||||
|
||||
function trimOrNull(value) {
|
||||
if (value === undefined || value === null) {
|
||||
|
|
@ -73,6 +81,22 @@ function normalizeOptionalDigits(value, label) {
|
|||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeNtsValidateText(value, fieldName, { required = false } = {}) {
|
||||
const normalized = trimOrNull(value);
|
||||
if (!normalized) {
|
||||
if (required) {
|
||||
throw new Error(`Provide ${fieldName} for each business.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxLength = NTS_VALIDATE_TEXT_FIELD_LIMITS[fieldName];
|
||||
if (maxLength && normalized.length > maxLength) {
|
||||
throw new Error(`Provide ${fieldName} up to ${maxLength} characters.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeNtsBusinessStatusQuery(body = {}) {
|
||||
return {
|
||||
b_no: normalizeNtsBusinessNumbers(body.b_no ?? body.business_numbers ?? body.businessNumbers)
|
||||
|
|
@ -84,10 +108,11 @@ function normalizeNtsBusinessValidateItem(item) {
|
|||
throw new Error("Each business must be an object.");
|
||||
}
|
||||
|
||||
const pNm = trimOrNull(item.p_nm ?? item.owner_name ?? item.ownerName ?? item.representative_name);
|
||||
if (!pNm) {
|
||||
throw new Error("Provide p_nm (representative name) for each business.");
|
||||
}
|
||||
const pNm = normalizeNtsValidateText(
|
||||
item.p_nm ?? item.owner_name ?? item.ownerName ?? item.representative_name,
|
||||
"p_nm",
|
||||
{ required: true }
|
||||
);
|
||||
|
||||
const normalized = {
|
||||
b_no: normalizeBusinessNumber(item.b_no ?? item.business_number ?? item.businessNumber),
|
||||
|
|
@ -96,7 +121,7 @@ function normalizeNtsBusinessValidateItem(item) {
|
|||
};
|
||||
|
||||
for (const key of NTS_VALIDATE_OPTIONAL_TEXT_FIELDS) {
|
||||
const value = trimOrNull(item[key]);
|
||||
const value = normalizeNtsValidateText(item[key], key);
|
||||
if (value) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
|
|
@ -104,6 +129,9 @@ function normalizeNtsBusinessValidateItem(item) {
|
|||
|
||||
const corpNo = normalizeOptionalDigits(item.corp_no ?? item.corpNo, "corp_no");
|
||||
if (corpNo) {
|
||||
if (!/^\d{13}$/.test(corpNo)) {
|
||||
throw new Error("Provide valid corp_no as 13 digits.");
|
||||
}
|
||||
normalized.corp_no = corpNo;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2744,7 +2744,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
reply.code(502);
|
||||
return {
|
||||
error: "proxy_error",
|
||||
message: error.message,
|
||||
message: "NTS business upstream request failed.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,24 @@ test("NTS business normalizers validate status and authenticity payloads", () =>
|
|||
() => normalizeNtsBusinessStatusQuery({ b_no: tooManyBusinessNumbers }),
|
||||
/up to 100/
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeNtsBusinessValidateQuery({
|
||||
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", corp_no: "123" }]
|
||||
}),
|
||||
/corp_no/
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeNtsBusinessValidateQuery({
|
||||
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍".repeat(31) }]
|
||||
}),
|
||||
/p_nm/
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeNtsBusinessValidateQuery({
|
||||
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", b_adr: "가".repeat(501) }]
|
||||
}),
|
||||
/b_adr/
|
||||
);
|
||||
});
|
||||
|
||||
test("NTS business status route proxies POST body with service key server-side", async (t) => {
|
||||
|
|
@ -418,7 +436,7 @@ test("NTS business route maps upstream fetch failures to 502 without caching", a
|
|||
|
||||
assert.equal(first.statusCode, 502);
|
||||
assert.equal(firstBody.error, "proxy_error");
|
||||
assert.match(firstBody.message, /network down/);
|
||||
assert.equal(firstBody.message, "NTS business upstream request failed.");
|
||||
|
||||
const second = await app.inject({
|
||||
method: "POST",
|
||||
|
|
@ -429,6 +447,33 @@ test("NTS business route maps upstream fetch failures to 502 without caching", a
|
|||
assert.equal(calls, 2, "fetch failures must not be cached");
|
||||
});
|
||||
|
||||
test("NTS business route does not leak service keys from upstream fetch exception messages", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async (url) => {
|
||||
throw new Error(`proxy tunnel failed for ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "super-secret-data-go-key" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/nts-business/status",
|
||||
payload: { b_no: ["1234567890"] }
|
||||
});
|
||||
const body = response.json();
|
||||
const bodyText = JSON.stringify(body);
|
||||
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.equal(body.error, "proxy_error");
|
||||
assert.equal(body.message, "NTS business upstream request failed.");
|
||||
assert.equal(bodyText.includes("super-secret-data-go-key"), false);
|
||||
assert.equal(bodyText.includes("serviceKey"), false);
|
||||
});
|
||||
|
||||
test("health endpoint stays public and reports auth/upstream status", async (t) => {
|
||||
const app = buildServer({
|
||||
provider: async () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ from typing import Any
|
|||
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
|
||||
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
||||
BATCH_LIMIT = 100
|
||||
VALIDATE_TEXT_FIELD_LIMITS = {
|
||||
"p_nm": 30,
|
||||
"p_nm2": 30,
|
||||
"b_nm": 200,
|
||||
"b_sector": 100,
|
||||
"b_type": 100,
|
||||
"b_adr": 500,
|
||||
}
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
|
|
@ -63,6 +71,28 @@ def normalize_start_date(value: Any) -> str:
|
|||
return normalized
|
||||
|
||||
|
||||
def normalize_validate_text(value: Any, field_name: str, *, required: bool = False) -> str | None:
|
||||
text = _text_or_none(value)
|
||||
if not text:
|
||||
if required:
|
||||
raise ValueError(f"{field_name}을(를) 입력하세요.")
|
||||
return None
|
||||
max_length = VALIDATE_TEXT_FIELD_LIMITS.get(field_name)
|
||||
if max_length and len(text) > max_length:
|
||||
raise ValueError(f"{field_name}은(는) {max_length}자 이하여야 합니다.")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_corp_no(value: Any) -> str | None:
|
||||
raw = _text_or_none(value)
|
||||
if not raw:
|
||||
return None
|
||||
normalized = re.sub(r"\D", "", raw)
|
||||
if not re.fullmatch(r"\d{13}", normalized):
|
||||
raise ValueError("corp_no는 숫자 13자리여야 합니다.")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
|
||||
numbers = [normalize_business_number(value) for value in business_numbers]
|
||||
numbers = list(dict.fromkeys(numbers))
|
||||
|
|
@ -74,9 +104,7 @@ def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
|
|||
|
||||
|
||||
def build_validate_business(**kwargs: Any) -> dict[str, str]:
|
||||
p_nm = _text_or_none(kwargs.get("p_nm"))
|
||||
if not p_nm:
|
||||
raise ValueError("대표자 성명(p_nm)을 입력하세요.")
|
||||
p_nm = normalize_validate_text(kwargs.get("p_nm"), "p_nm", required=True)
|
||||
|
||||
business = {
|
||||
"b_no": normalize_business_number(kwargs.get("b_no")),
|
||||
|
|
@ -85,13 +113,13 @@ def build_validate_business(**kwargs: Any) -> dict[str, str]:
|
|||
}
|
||||
|
||||
for key in ("p_nm2", "b_nm", "b_sector", "b_type", "b_adr"):
|
||||
value = _text_or_none(kwargs.get(key))
|
||||
value = normalize_validate_text(kwargs.get(key), key)
|
||||
if value:
|
||||
business[key] = value
|
||||
|
||||
corp_no = _text_or_none(kwargs.get("corp_no"))
|
||||
corp_no = normalize_corp_no(kwargs.get("corp_no"))
|
||||
if corp_no:
|
||||
business["corp_no"] = re.sub(r"\D", "", corp_no)
|
||||
business["corp_no"] = corp_no
|
||||
return business
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import urllib.error
|
||||
|
||||
|
|
@ -51,6 +53,43 @@ class NtsBusinessNormalizationTest(unittest.TestCase):
|
|||
},
|
||||
)
|
||||
|
||||
def test_build_validate_business_rejects_malformed_or_oversized_optional_fields(self):
|
||||
base = {"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "corp_no"):
|
||||
build_validate_business(**base, corp_no="123")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "p_nm"):
|
||||
build_validate_business(b_no="1234567890", start_dt="20200101", p_nm="홍" * 31)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "b_adr"):
|
||||
build_validate_business(**base, b_adr="가" * 501)
|
||||
|
||||
def test_skill_local_helper_matches_runtime_validation_behavior(self):
|
||||
helper_path = Path(__file__).resolve().parents[1] / "nts-business-registration" / "scripts" / "nts_business_registration.py"
|
||||
spec = importlib.util.spec_from_file_location("skill_local_nts_business_registration", helper_path)
|
||||
self.assertIsNotNone(spec)
|
||||
self.assertIsNotNone(spec.loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
self.assertEqual(
|
||||
module.build_validate_business(
|
||||
b_no="123-45-67890",
|
||||
start_dt="2020.01.31",
|
||||
p_nm="홍길동",
|
||||
corp_no="110111-1234567",
|
||||
),
|
||||
{
|
||||
"b_no": "1234567890",
|
||||
"start_dt": "20200131",
|
||||
"p_nm": "홍길동",
|
||||
"corp_no": "1101111234567",
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "corp_no"):
|
||||
module.build_validate_business(b_no="1234567890", start_dt="20200101", p_nm="홍길동", corp_no="abc")
|
||||
|
||||
|
||||
class NtsBusinessProxyTest(unittest.TestCase):
|
||||
def test_query_status_posts_to_proxy_route(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue