개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Backend

Node.js 22 Permission Model 실전 가이드 — `--allow-fs-read`로 프로세스 권한을 최소화하고 공급망 공격 표면을 줄이는 법

npm 생태계에서 공급망 공격은 꾸준히 늘고 있다. 패키지 하나가 침해되는 순간, npm install로 당겨온 수백 개의 의존성 중 어느 것도 온전히 신뢰하기 어려워진다. 락파일 관리, 의존성 감사, npm audit은 여전히 유효하지만, 침해가 발생한 이후를 위한 두 번째 방어선도 필요하다.

Node.js Permission Model이 바로 그 역할이다. 악성 패키지가 프로세스 안에서 실행되더라도 파일시스템 접근을 런타임 레벨에서 틀어막아 피해 범위를 최소화한다. 이 글에서는 실제 서비스에 어떻게 적용하는지, 그리고 어떤 함정을 피해야 하는지 살펴본다.


먼저 솔직하게: Permission Model이 막는 것과 막지 못하는 것

적용 전에 이 한계를 명확히 알아야 한다. Node.js Permission Model은 네트워크 접근을 제어하지 않는다. 이건 Deno와 Node.js의 결정적인 차이점이다.

지원하는 제어 대상은 다음과 같다:

플래그 제어 대상
--allow-fs-read=<경로> 파일시스템 읽기
--allow-fs-write=<경로> 파일시스템 쓰기
--allow-child-process 자식 프로세스 스폰
--allow-worker Worker Threads
--allow-addons 네이티브 애드온
--allow-wasi WASI 인스턴스

아웃바운드 HTTP/TCP는 Permission Model의 통제 밖이다. 침해된 패키지가 외부 서버로 데이터를 전송하려 해도 Permission Model은 개입하지 않는다. 네트워크 egress 제어는 Kubernetes NetworkPolicy, 서비스 메시의 egress 정책, 또는 컨테이너 레벨 방화벽으로 보완해야 한다.

이 한계를 인지한 상태에서, 파일시스템과 프로세스 권한 영역에서 Permission Model이 제공하는 방어를 제대로 활용하는 것이 목표다.


핵심 개념

진입 플래그와 옵트인 모델

Permission Model의 진입점은 단일 플래그다.

  • --experimental-permission — Node.js 20~22에서 사용하는 이름
  • --permission — Node.js 23.5.0부터 추가된 개명 버전

이 플래그 하나만 지정하면 파일시스템·자식 프로세스·워커 스레드 등 제어 가능한 모든 자원 접근이 기본 차단된다. 이후 필요한 권한만 개별 플래그로 개방하는 방식이다.

bash
# Node.js 22 기준
node --experimental-permission \
  --allow-fs-read=/app/src,/app/node_modules \
  --allow-fs-write=/app/logs \
  server.js

경로는 단일 파일, 디렉터리, 와일드카드를 지원하고 쉼표로 복수 경로를 나열할 수 있다. Permission Model은 옵트인 방식이라 플래그 없이 실행하면 기존 동작 그대로다. 코드 변경 없이 플래그만 추가해 점진적으로 도입할 수 있다는 것이 가장 큰 실용적 장점이다.

활성화된 상태에서 파일 접근이 시도될 때의 흐름은 이렇다:

다이어그램 1

process.permission.has() — 런타임 권한 확인

코드 안에서 현재 프로세스의 권한 상태를 직접 확인할 수 있다. 단순히 오류를 앞당기는 것보다, 권한 여부에 따라 동작을 분기하거나 폴백을 제공하는 패턴에서 유용하다.

js
process.permission.has('fs.write');                    // true/false
process.permission.has('fs.read', '/etc/passwd');      // false
process.permission.has('fs.read', '/app/config');      // true (허용 경로인 경우)
process.permission.has('child');                       // true/false

실용적인 활용 예시는 오류를 던지는 것이 아니라 폴백 처리다:

js
import { createReadStream } from 'fs';
 
function readOrFallback(filePath, fallbackData) {
  if (!process.permission.has('fs.read', filePath)) {
    // 권한이 없으면 기본값으로 조용히 대체
    return fallbackData;
  }
  return createReadStream(filePath);
}

실전 적용

시나리오 1: Express API 서버 최소 권한 실행

가장 흔한 케이스다. API 서버가 읽어야 하는 경로와 써야 하는 경로를 명시적으로 선언한다.

bash
node --experimental-permission \
  --allow-fs-read=/app \
  --allow-fs-write=/app/tmp,/app/logs \
  index.js

이 설정으로는 node_modules 내 침해된 패키지가 /etc/passwd나 .env를 읽으려 해도 ERR_ACCESS_DENIED로 차단된다. 아웃바운드 HTTP 요청은 Permission Model이 개입하지 않으므로, 네트워크 레벨 제어는 인프라에서 별도로 구성해야 한다는 점을 함께 기억해야 한다.

시나리오 2: 데이터 처리 배치 스크립트

네트워크가 불필요한 ETL 잡이나 마이그레이션 스크립트는 Permission Model 도입이 가장 쉬운 대상이다. 접근 경로가 입력·출력 두 개뿐이라 허용 목록을 완성하기 어렵지 않다.

bash
node --experimental-permission \
  --allow-fs-read=/data/input \
  --allow-fs-write=/data/output \
  etl-job.js

네트워크 egress가 원천적으로 필요 없는 워크로드라면, 별도 --allow-net 같은 플래그가 없다는 사실이 오히려 이점이 된다. 인프라 방화벽에서 해당 프로세스의 아웃바운드를 막으면 데이터 유출 경로 자체가 없어진다.

시나리오 3: 컨테이너 환경에서 Defense-in-Depth

Docker/Kubernetes에서는 컨테이너 레벨 보안과 Permission Model을 함께 구성하면 서로 다른 지점을 이중으로 방어한다.

dockerfile
CMD ["node", "--experimental-permission", "--allow-fs-read=/app", "--allow-fs-write=/tmp", "server.js"]

JSON 배열 형식(CMD [...])에서는 줄 이음 문자 \를 사용할 수 없다. 여러 플래그를 사용할 때는 한 줄로 나열하거나 shell 형식을 쓴다.

yaml
# kubernetes securityContext
securityContext:
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false

각 방어 레이어가 어떤 시점에 어떤 위협을 막는지 정리하면 다음과 같다:

다이어그램 2

설치 시점과 런타임 시점은 서로 다른 단계다. 패키지 매니저 레벨 보호가 뚫려도 런타임 레이어가 작동하고, Node.js 프로세스 레벨이 우회되어도 컨테이너 레이어가 남아 있다.

시나리오 4: 서드파티 플러그인 샌드박싱

신뢰도가 낮은 서드파티 코드를 Worker Threads로 분리 실행할 때, 워커 생성 자체를 메인 프로세스의 --allow-worker 플래그로 제어하는 패턴이다.

js
import { Worker } from 'worker_threads';
 
// 메인 프로세스에 --allow-worker가 없으면 이 줄에서 ERR_ACCESS_DENIED
const worker = new Worker('./plugin.js', {
  workerData: { inputPath: '/data/input' }
});
 
worker.on('error', (err) => {
  if (err.code === 'ERR_ACCESS_DENIED') {
    console.error('플러그인이 허용되지 않은 자원에 접근 시도함');
  }
});

메인 프로세스의 Permission 정책은 워커에 자동 상속되지 않는다. 워커를 더 좁게 제한하고 싶다면, 워커 스크립트를 별도 node 프로세스로 스폰하고 해당 프로세스에 독립적인 플래그를 전달하는 방식을 써야 한다.


실무에서 흔한 실수

자식 프로세스에 권한이 상속된다고 착각하기

child_process로 스폰한 하위 프로세스는 부모의 Permission 정책을 전혀 받지 않는다.

js
import { spawn } from 'child_process';
 
// child.js는 부모의 Permission 정책과 무관하게 실행됨
const child = spawn('node', ['child.js']);
 
// 자식에도 적용하려면 플래그를 직접 전달해야 함
const safeChild = spawn('node', [
  '--experimental-permission',
  '--allow-fs-read=/child/data',
  'child.js'
]);

허용 경로 안에 심볼릭 링크 두기

/app/data를 허용했는데 /app/data/link → /etc처럼 심볼릭 링크가 걸려 있으면 허용 범위 밖 경로에 접근하는 경로가 생긴다. 허용 경로 안의 심볼릭 링크를 주기적으로 점검하는 습관이 필요하다.

--env-file과 Permission Model의 순서 오해

--env-file은 Permission Model이 적용되기 전에 파일을 읽는다. 환경변수 파일 로딩은 Permission Model의 보호 대상 밖에 있는 초기화 단계다.

node_modules 내부 접근 경로 빠뜨리기

패키지들이 내부적으로 접근하는 경로를 모두 파악하지 못하면 서비스가 뜨지 않는다. 처음엔 --allow-fs-read=/app처럼 넓게 잡고, ERR_ACCESS_DENIED 로그를 보며 점진적으로 좁혀나가는 방식이 현실적이다.


Deno와의 비교

네트워크 egress 제어를 놓고 비교하면 Node.js와 Deno의 차이가 선명하게 드러난다.

항목 Node.js Permission Model Deno 권한 모델
파일시스템 읽기/쓰기 제어 ✅ ✅
네트워크 egress 제어 ❌ (인프라 레벨 보완 필요) ✅ (--allow-net)
자식 프로세스 제어 ✅ ✅
환경변수 접근 제어 ❌ ✅
기본 보안 모드 옵트인 secure-by-default
기존 npm 생태계 호환 ✅ 부분적

Deno는 처음부터 secure-by-default로 설계되어 명시적으로 허용하지 않으면 아무것도 할 수 없다. Node.js는 기존 생태계와의 호환성 때문에 옵트인을 선택했고, 코드 변경 없이 플래그만으로 도입할 수 있다는 현실적인 장점이 있다. 다만 런타임 샌드박싱을 파일시스템 이상으로 확장하려면 인프라 레벨 제어가 필수적으로 따라와야 한다.


마치며

Node.js Permission Model은 침해를 막는 도구가 아니라, 침해 이후 피해를 줄이는 도구다. 악성 패키지가 이미 프로세스 안에서 실행되고 있더라도 파일시스템 접근을 제한해 민감한 파일 탈취와 의도치 않은 쓰기를 차단할 수 있다.

네트워크 egress는 막지 못하므로 Kubernetes NetworkPolicy, 컨테이너 보안 정책과 함께 써야 의미 있는 다층 방어가 된다. 각 레이어가 막는 대상이 다르기 때문에, 어느 하나가 우회되더라도 다음 레이어가 남아 있다는 구조적 이점이 생긴다.

적용 순서를 고민 중이라면:

  • 배치 스크립트부터 시작 — ETL 잡이나 마이그레이션 스크립트는 접근 경로가 단순해서 허용 목록을 만들기 가장 쉽고, 서비스 코드보다 리스크도 낮다.
  • 개발 환경에서 ERR_ACCESS_DENIED 수집 — 프로덕션에 바로 적용하기 어렵다면, 개발 환경에서 플래그를 활성화하고 발생하는 오류를 수집해 허용 목록을 점진적으로 완성해 나간다.
  • 컨테이너 배포에 readOnlyRootFilesystem과 병행 — Kubernetes 환경이라면 securityContext.readOnlyRootFilesystem: true와 CMD의 Permission 플래그를 함께 적용해 두 레이어를 동시에 확보한다.

참고 자료

  • Permissions | Node.js v22.15.0 공식 문서
  • Permissions | Node.js v26.5.0 공식 문서
  • Node.js 22 Permission Model: Deep Dive & Benchmarks
  • Enable Node.js Permission Model | Learn Node.js Security
  • 5 Node.js Permission Model Changes Every API Developer Should Know in 2026 - DEV Community
  • Hardening Your Node.js App Against Supply Chain & RCE Attacks - DEV Community
  • Secure Node.js Applications from Supply Chain Attacks | Auth0 Blog
  • Node.js Security Best Practices for 2026 | Medium
  • How Deno protects against npm exploits | Deno 공식 블로그
  • Node.js June 2026 Security Releases
  • nodejs/node permissions.md — GitHub
#Node.js#Permission Model#공급망 보안#npm#최소 권한#파일시스템 보안
공유하기

목차

먼저 솔직하게: Permission Model이 막는 것과 막지 못하는 것핵심 개념진입 플래그와 옵트인 모델process.permission.has()실전 적용시나리오 1: Express API 서버 최소 권한 실행시나리오 2: 데이터 처리 배치 스크립트시나리오 3: 컨테이너 환경에서 Defense-in-Depth시나리오 4: 서드파티 플러그인 샌드박싱실무에서 흔한 실수Deno와의 비교마치며참고 자료

추천 포스트

API 스키마 한 벌로 OpenAPI 3.1·gRPC Protobuf·TypeScript 클라이언트를 동시에 뽑아내기 — TypeSpec 1.0 실전 가이드
Backend

API 스키마 한 벌로 OpenAPI 3.1·gRPC Protobuf·TypeScript 클라이언트를 동시에 뽑아내기 — TypeSpec 1.0 실전 가이드

작년에 직접 겪은 일입니다. 결제팀이 /payments/{id} 응답에서 settled at 필드를 completedAt 으로 이름을 바꿨습니다. JIRA 티켓도 있었고, 코드 리뷰도 통과했고, 사내 Slack에 공지도 됐습니다. 그런데 주문팀의 BFF는 여전히 set…

2026년 07월 20일읽는 데 23분
공개키만 남기고 비밀번호를 걷어내기: Node.js와 SimpleWebAuthn으로 패스키 등록·인증을 구현하고 기존 로그인과 단계적으로 통합하기
Backend

공개키만 남기고 비밀번호를 걷어내기: Node.js와 SimpleWebAuthn으로 패스키 등록·인증을 구현하고 기존 로그인과 단계적으로 통합하기

인증 시스템을 처음부터 다시 짜야 한다는 얘기를 들으면 솔직히 긴장됩니다. 비밀번호 해싱, 세션 관리, 2FA 연동까지 이미 쌓아온 코드가 있는데, 거기에 WebAuthn이라는 새로운 개념을 얹어야 한다니. 저도 처음에 W3C 명세를 펼쳤다가 CBOR, COSE, at…

2026년 07월 19일읽는 데 27분
무중단 PostgreSQL 스키마 마이그레이션 실전 가이드 — Expand-Contract 패턴으로 컬럼 리네임부터 타입 변경까지 롤백 없이 배포하기
Backend

무중단 PostgreSQL 스키마 마이그레이션 실전 가이드 — Expand-Contract 패턴으로 컬럼 리네임부터 타입 변경까지 롤백 없이 배포하기

NOT VALID + VALIDATE CONSTRAINT 조합은 PostgreSQL 12+에서 특히 강력합니다. VALIDATE CONSTRAINT 는 SHARE UPDATE EXCLUSIVE 락만 잡기 때문에 SELECT는 물론이고 대부분의 DML도 막지 않습니다.…

2026년 07월 19일읽는 데 11분
`pg`, `ioredis`, `@aws-sdk/client-s3`를 지워도 되는 이유 — Bun 네이티브 클라이언트로 스토리지 드라이버 의존성 줄이기
Backend

`pg`, `ioredis`, `@aws-sdk/client-s3`를 지워도 되는 이유 — Bun 네이티브 클라이언트로 스토리지 드라이버 의존성 줄이기

새 프로젝트를 시작할 때마다 npm install pg ioredis @aws sdk/client s3 를 치고, @types/ 패키지를 맞추고, 버전 충돌을 잡다가 정작 코드는 한 줄도 못 쓰고 오후가 지나가는 경험 — 낯설지 않으실 겁니다. Bun 1.2가 SQL·…

2026년 07월 19일읽는 데 20분
Fastify v5 + TypeBox로 TypeScript REST API 구축하기
Backend

Fastify v5 + TypeBox로 TypeScript REST API 구축하기

Express로 API를 작성할 때 생기는 구조적 문제를 코드로 먼저 보겠습니다. TypeScript 타입, 런타임 검증 규칙, API 문서가 각각 다른 곳에 있습니다. 셋 중 하나가 바뀌어도 나머지가 자동으로 따라오지 않습니다. Fastify v5 + TypeBox는…

2026년 07월 19일읽는 데 15분
Node.js 비동기 작업을 안정적으로 처리하는 BullMQ 5 잡 큐 — 재시도 전략·Dead Letter Queue·Sandboxed Processor·Prometheus 메트릭 연동까지
Backend

Node.js 비동기 작업을 안정적으로 처리하는 BullMQ 5 잡 큐 — 재시도 전략·Dead Letter Queue·Sandboxed Processor·Prometheus 메트릭 연동까지

회원 가입 API를 만들다 보면 어느 순간 이런 생각이 듭니다. "환영 이메일을 발송하는 동안 HTTP 응답이 블로킹되는 게 맞는 걸까?" 저도 처음엔 그냥 await sendEmail() 을 응답 전에 넣었는데, SendGrid 타임아웃이 터지는 순간 모든 가입 요청…

2026년 07월 19일읽는 데 23분