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부터 추가된 개명 버전
이 플래그 하나만 지정하면 파일시스템·자식 프로세스·워커 스레드 등 제어 가능한 모든 자원 접근이 기본 차단된다. 이후 필요한 권한만 개별 플래그로 개방하는 방식이다.
# Node.js 22 기준
node --experimental-permission \
--allow-fs-read=/app/src,/app/node_modules \
--allow-fs-write=/app/logs \
server.js경로는 단일 파일, 디렉터리, 와일드카드를 지원하고 쉼표로 복수 경로를 나열할 수 있다. Permission Model은 옵트인 방식이라 플래그 없이 실행하면 기존 동작 그대로다. 코드 변경 없이 플래그만 추가해 점진적으로 도입할 수 있다는 것이 가장 큰 실용적 장점이다.
활성화된 상태에서 파일 접근이 시도될 때의 흐름은 이렇다:
process.permission.has() — 런타임 권한 확인
코드 안에서 현재 프로세스의 권한 상태를 직접 확인할 수 있다. 단순히 오류를 앞당기는 것보다, 권한 여부에 따라 동작을 분기하거나 폴백을 제공하는 패턴에서 유용하다.
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실용적인 활용 예시는 오류를 던지는 것이 아니라 폴백 처리다:
import { createReadStream } from 'fs';
function readOrFallback(filePath, fallbackData) {
if (!process.permission.has('fs.read', filePath)) {
// 권한이 없으면 기본값으로 조용히 대체
return fallbackData;
}
return createReadStream(filePath);
}실전 적용
시나리오 1: Express API 서버 최소 권한 실행
가장 흔한 케이스다. API 서버가 읽어야 하는 경로와 써야 하는 경로를 명시적으로 선언한다.
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 도입이 가장 쉬운 대상이다. 접근 경로가 입력·출력 두 개뿐이라 허용 목록을 완성하기 어렵지 않다.
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을 함께 구성하면 서로 다른 지점을 이중으로 방어한다.
CMD ["node", "--experimental-permission", "--allow-fs-read=/app", "--allow-fs-write=/tmp", "server.js"]JSON 배열 형식(CMD [...])에서는 줄 이음 문자 \를 사용할 수 없다. 여러 플래그를 사용할 때는 한 줄로 나열하거나 shell 형식을 쓴다.
# kubernetes securityContext
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false각 방어 레이어가 어떤 시점에 어떤 위협을 막는지 정리하면 다음과 같다:
설치 시점과 런타임 시점은 서로 다른 단계다. 패키지 매니저 레벨 보호가 뚫려도 런타임 레이어가 작동하고, Node.js 프로세스 레벨이 우회되어도 컨테이너 레이어가 남아 있다.
시나리오 4: 서드파티 플러그인 샌드박싱
신뢰도가 낮은 서드파티 코드를 Worker Threads로 분리 실행할 때, 워커 생성 자체를 메인 프로세스의 --allow-worker 플래그로 제어하는 패턴이다.
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 정책을 전혀 받지 않는다.
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