본문으로 건너뛰기
언어

@plott/webkit-server0.4.0

Node 서버 쪽 안전 도구 — 시간 일정 비교(safeEqual), 사설망·메타데이터 주소를 막는 외부 호출(SSRF: 주소 검사·DNS 확인·리다이렉트 재검사·크기·시간 한도), 서명 검증, 개발용 인증 우회의 삼중 잠금. Node 전용

설치

pnpm
pnpm add @plott/webkit-server@0.4.0

진입점(exports)

import타입파일
@plott/webkit-server./dist/index.d.ts./dist/index.js

Node 서버 코드가 쓰는 안전 도구 모음입니다. 읽는 사람은 웹훅을 받고, 사용자가 준 주소를 서버에서 부르고, 개발용 우회를 켜는 백엔드·BFF 개발자입니다.

Node 전용입니다(node:crypto·node:dns·node:http 사용). 브라우저·Edge 번들에 넣지 않습니다. 런타임 의존성이 없습니다. 이 패키지가 던지는 오류 메시지는 @plott/webkit-server: 로 시작합니다.

진입점

진입점어디서 도나내보내는 것
@plott/webkit-serverNode 서버(Route Handler·BFF·스크립트)safeEqual, assertPublicHttpUrl, UnsafeUrlError, isPublicIp, safeFetch, SafeFetchError, devBypass, DEPLOY_MARKERS, verifyHmac, slackBase

붙이기

ts
import { safeEqual, safeFetch, assertPublicHttpUrl, verifyHmac, slackBase, devBypass } from "@plott/webkit-server";
declare const req: Request;declare const userUrl: string;declare const input: string;declare const secret: string;declare const rawBody: string;declare const sig: string;declare const ts: string;declare const devUser: { id: string };
export async function handle() {  if (!safeEqual(req.headers.get("x-token") ?? "", process.env.TOKEN ?? "")) return new Response(null, { status: 401 });
  const res = await safeFetch(userUrl, { timeoutMs: 5_000, maxBytes: 1_000_000 });  res.status;  res.headers;  res.body;  res.url;
  assertPublicHttpUrl(input);
  verifyHmac({ secret, payload: rawBody, signature: req.headers.get("x-signature")! });  verifyHmac({    secret,    payload: rawBody,    signature: sig,    prefix: "v0=",    timestamp: ts,    toleranceSec: 300,    signedPayload: slackBase,  });
  if (devBypass()) return devUser;}
호출동작
safeEqual토큰 비교에 === 를 쓰지 않습니다.
safeFetch사용자가 준 주소(웹훅 URL, 이미지 미리보기 등)를 부릅니다. res.body 는 Buffer, res.url 은 최종 주소입니다.
assertPublicHttpUrl저장 전에 주소 문자열만 거릅니다. DNS 는 보지 않으며, https 공인 주소가 아니면 UnsafeUrlError 를 던집니다.
verifyHmac웹훅 서명을 검증합니다. payload 는 원문 본문 그대로여야 합니다. 둘째 호출은 Slack 형식입니다.
devBypass개발용 인증 우회. NODE_ENV 가 development 또는 test 이고 DEV_AUTH_BYPASS=1 일 때만 참입니다.

테스트에서 로컬 서버를 부르려면 그 호스트만 엽니다. 기본값은 약해지지 않습니다.

ts
import { safeFetch } from "@plott/webkit-server";
await safeFetch("http://127.0.0.1:4010/health", { allowHttp: true, allowHosts: ["127.0.0.1"] });

기본으로 지키는 것

  • safeEqual 은 길이가 달라도 던지지 않고 시간이 새지 않습니다. 두 값을 SHA-256 으로 접어 같은 길이로 만든 뒤 timingSafeEqual 로 비교합니다. === 는 첫 다른 글자에서 멈춰 응답 시간으로 토큰을 한 글자씩 알아낼 수 있고, timingSafeEqual 을 그대로 쓰면 길이가 다를 때 던집니다. 문자열은 UTF-8 로 봅니다. 문자열도 바이트열도 아닌 값(빠진 환경 변수 undefined)은 «다름» 입니다 — 둘 다 undefined 라고 통과시키지 않습니다.
  • assertPublicHttpUrl 은 허용 목록으로 막습니다. 공인 주소가 아닌 것은 전부 거부합니다: 사설(10/8, 172.16/12, 192.168/16)·루프백·링크 로컬(169.254.169.254 클라우드 메타데이터 포함)·CGNAT(100.64/10)·멀티캐스트·예약, IPv6 의 ::1·fc00::/7·fe80::/10, IPv4 를 품은 ::ffff:127.0.0.1·NAT64·6to4(안의 IPv4 로 판정). http://2130706433·http://0x7f.1·http://0177.0.0.1·http://127.1 같은 10진/8진/16진 표기는 URL 파서가 점 표기로 바꾼 뒤 같은 범위로 막습니다. 숫자·점·x 로만 된 이름처럼 IP 로 해석되지 않는 표기는 host 코드로 거부합니다. localhost·*.localhost·*.local·*.internal·*.localdomain·*.home.arpa·*.lan, localhost.·127.0.0.1. 같은 끝점 표기도 막습니다. 그리고 user:pw@host 사용자 정보, http(s) 가 아닌 스킴(file:·gopher:)도 막습니다. 기본은 https 만이고 allowHttp 로 엽니다.
  • 이름이 사설 주소로 풀리는 경우는 safeFetch 가 막습니다. assertPublicHttpUrl 은 문자열만 보므로 evil.example.com → 10.0.0.5 를 알 수 없습니다. safeFetch 는 A/AAAA «모든» 답을 검사하고, 하나라도 사설이면 private-resolution 으로 거부합니다.
  • safeFetch 는 검사한 주소로만 연결합니다(DNS 리바인딩 방지). 확인과 연결 사이에 DNS 가 바뀌어 사설 주소를 돌려줘도(TOCTOU) 연결은 검사한 주소에 고정됩니다. 요청 하나에 DNS 확인은 한 번뿐이고, TLS 서버 이름·Host 는 원래 호스트로 유지됩니다.
  • 리다이렉트는 직접 따라가며 매 홉을 다시 검사합니다. 공인 사이트가 http://169.254.169.254/ 로 넘기는 우회를 막습니다. 다른 출처로 가면 forwardOnRedirect(기본 accept·accept-language·user-agent) 밖의 호출자 헤더를 «모두» 뗍니다(x-api-key 처럼 이름을 알 수 없는 비밀까지). 같은 출처 리다이렉트에는 영향이 없습니다. 303(그리고 POST 의 301/302)은 GET 으로 바꿔 본문을 버리고, 본문을 다시 보내야 하는 307/308 이 다른 출처로 가면 bad-redirect 로 던집니다(allowCrossOriginBodyRedirect: true 로만 허용). 기본 3번, 넘으면 too-many-redirects 로 던집니다.
  • 크기는 스트리밍 중에 제한합니다. Content-Length 가 한도를 넘으면 본문을 읽기 전에, 아니면 한도를 넘는 순간 연결을 끊고 too-large 로 던집니다(기본 5MB). 응답을 통째로 메모리에 올려 서버가 죽는 일을 막습니다.
  • 시간 제한은 리다이렉트를 포함한 전체에 겁니다(기본 10초). URL 검사 뒤 DNS 확인·연결·헤더·본문 어느 단계에서 멈춰도 같은 기한에 timeout 으로 끊기고(느린 리졸버도 timeoutMs 에 끝납니다), 검사에서 던져도 타이머는 남지 않습니다. 끊긴 뒤 늦게 거절하는 리졸버가 처리되지 않은 거절을 만들지 않습니다. 압축 해제는 하지 않아(accept-encoding 을 보내지 않음) 압축 폭탄이 없습니다.
  • allowHosts 는 그 호스트 «하나» 만 엽니다. 목록에 없는 사설 호스트(10.0.0.1)는 여전히 막히고, 스킴·사용자 정보·포트 검사도 그대로입니다. 비교는 정확히 일치이고 대소문자와 끝점 하나를 무시하며 와일드카드는 없습니다. 목록은 리다이렉트 홉에도 같이 적용되어, 목록 밖의 사설 호스트로 가는 리다이렉트는 막힙니다. 목록이 비어 있으면 아무것도 열지 않습니다.
  • devBypass 는 삼중 잠금입니다. NODE_ENV 가 정확히 development/test, 플래그가 정확히 "1"(true·yes 아님), 그리고 운영 표지가 없어야 true 입니다. 배포된 컨테이너에 NODE_ENV=test 가 남아 있는 경우를 막는 것은 아래 표지뿐입니다(표지가 없는 호스팅이면 refuseWhenSet 에 그 플랫폼의 변수를 더하세요). 운영 표지는 값이 비어 있지 않을 때만 표지로 봅니다: VERCEL_ENV 가 development 가 아닌 값(미리보기 배포도 공개), NETLIFY=true, VERCEL·WEBSITE_SITE_NAME(Azure)·GAE_SERVICE·GAE_ENV·CF_PAGES, AWS_LAMBDA_FUNCTION_NAME·AWS_EXECUTION_ENV·ECS_CONTAINER_METADATA_URI(_V4)·KUBERNETES_SERVICE_HOST·K_SERVICE·FLY_APP_NAME·DYNO·RAILWAY_ENVIRONMENT·RENDER (DEPLOY_MARKERS) 와 refuseWhenSet 로 더한 이름. CI 는 표지가 아닙니다(CI 에서 테스트가 돌아야 합니다). 플래그 이름이 NEXT_PUBLIC_ 로 시작하면 번들러가 값을 클라이언트 코드에 박아 넣으므로 환경과 상관없이 던집니다.
  • verifyHmac 은 safeEqual 로 비교하고, 서명이 틀려도 던지지 않습니다. 빈 비밀(누구나 서명을 만들 수 있음)·알 수 없는 알고리즘이나 표기·toleranceSec 만 있고 타임스탬프가 없는 경우처럼 «설정 실수» 만 던집니다. 타임스탬프를 주면 서명 대상에 포함하고, toleranceSec 를 넘기는 과거·미래 요청(재전송)은 거부합니다. 숫자가 아닌 타임스탬프는 false 입니다. 서명은 파싱하기 «전» 의 원문 본문으로 계산해야 합니다.

그 밖

함수와 옵션

이름하는 일옵션과 기본값
safeEqual(a, b)두 비밀 값을 일정한 시간에 비교합니다.없음
isPublicIp(text)공인 인터넷 주소인지 판정합니다. 주소로 읽을 수 없으면 false 입니다.없음
assertPublicHttpUrl(input, options)공인 http(s) 주소인지 문자열만으로 검사하고 파싱한 URL 을 돌려줍니다. 막히면 UnsafeUrlError 를 던집니다.allowHttp false, allowPorts 제한 없음, allowHosts 빈 목록, blockedSuffixes .localhost·.local·.internal·.localdomain·.home.arpa·.lan(주면 이 목록을 대신하고 localhost 자체는 늘 막힙니다)
safeFetch(input, options)공인 주소만 부르는 HTTP 호출입니다. { status, headers, body, url } 을 돌려줍니다.timeoutMs 10000, maxBytes 5000000, maxRedirects 3, method GET, forwardOnRedirect accept·accept-language·user-agent, allowCrossOriginBodyRedirect false, resolve 는 dns.lookup(host, { all: true }), profile 없음
verifyHmac(input)웹훅 HMAC 서명이 맞는지 true/false 로 돌려줍니다.algorithm sha256, encoding hex, prefix 없음, now 는 Date.now(), signedPayload 는 ${timestamp}.${payload}(Stripe 방식)
slackBase(timestamp, payload)Slack 서명 대상 v0:{timestamp}:{본문} 바이트를 만듭니다. signedPayload 에 넣고 prefix: "v0=" 를 줍니다.없음
devBypass(env, options)개발용 인증 우회를 켜도 되는지 판정합니다.env 는 process.env, flag 는 "DEV_AUTH_BYPASS", refuseWhenSet 빈 목록
  • UnsafeUrlError.code: invalid-url·scheme·http-not-allowed·userinfo·host·port·private-host·private-ip·private-resolution.
  • SafeFetchError.code: timeout·too-large·too-many-redirects·bad-redirect·no-address·network. 확인기나 소켓 오류는 network 로 감싸고 원인을 cause 에 둡니다.
  • safeFetch 의 timeoutMs·maxBytes·maxRedirects 가 범위를 벗어나면 RangeError, 알 수 없는 profile 도 RangeError 입니다.
  • assertPublicHttpUrl 의 allowPorts 를 주지 않으면 포트를 제한하지 않습니다(예: 22·6379). 사용자가 준 주소를 부를 때는 safeFetch(url, { profile: "user-url" }) 를 쓰세요 — allowPorts: [80, 443], allowHttp: false, maxRedirects: 3 묶음이고, 같은 이름의 옵션을 직접 주면 그쪽이 이깁니다.
  • safeFetch 는 본문을 Buffer 로 돌려주며 3xx·4xx·5xx 도 던지지 않고 그대로 돌려줍니다. maxRedirects 가 0 이거나 Location 이 없는 3xx 는 따라가지 않습니다.
  • 기본 DNS 확인은 OS 확인기(/etc/hosts 포함)입니다. 다른 확인기는 resolve 로 넣습니다.
  • 막지 못하는 것: 공인 주소의 서비스가 사설망으로 되돌려 주는 프록시이거나, 공인 주소 자체가 내부 서비스인 경우. 네트워크 계층(송신 방화벽)이 마지막 방어선입니다.
  • 시험 범위: 로컬 node:http 서버, 가짜 DNS 확인기, http.request 가로채기(연결 고정 확인)로 위 항목을 모두 시험합니다. 실제 인터넷 호스트에 대한 https 호출과 IPv6 소켓 연결은 시험하지 않습니다.