카테고리 없음

플러그인 권한 스코프 설계: 최소 권한 원칙으로 공격 표면을 정밀하게 제한하는 법

seunghyeonlab 2026. 5. 14. 10:02

플러그인 하나가 시스템 전체를 뚫는 상황을 막고 싶다면, 권한 설계를 아키텍처 단계에서 결정해야 한다. 단순히 read/write로 이분화하는 것은 시작점도 안 된다. 리소스 유형, 작업 범위, 접근 제약을 3차원으로 정의하고, 선언-검증-감사의 자동화 루프를 구축하는 과정을 실제 코드와 함께 풀어낸다.

전체 권한 제어 흐름


왜 단순 권한 모델이 무너지는가

플러그인 시스템에서 권한 스코프는 곧 공격 표면이다. 플러그인 하나가 전체 파일시스템이나 환경변수에 접근할 수 있다면, 그 플러그인 하나의 취약점이 시스템 전체 침해로 이어진다. 실제 사고의 상당수는 "허용된 플러그인이 예상보다 넓은 범위에 접근했다"는 패턴이다.

흔히 저지르는 실수는 두 가지다. 첫째, 배포 편의를 위해 권한을 넉넉하게 부여한 뒤 축소를 미룬다. 둘째, 선언만 하고 런타임 검증을 생략한다. 이 두 가지가 겹치면 선언은 문서에 불과해지고 실제 접근은 무제한이 된다.

취약한 단순 모델 vs 3차원 스코프 모델


스코프 모델 설계: 3차원 선언

권한 구조를 타입으로 먼저 정의한다. 리소스 유형(file, network, env, db), 허용 액션 목록, 그리고 접근 가능한 범위를 추가로 제한하는 제약 조건이 한 세트를 이룬다.

interface PluginScope {
  resource: ResourceType;       // "file" | "network" | "env" | "db"
  actions: Action[];            // ["read"] | ["read", "write"] | ["execute"]
  constraints: ScopeConstraint;
}

interface ScopeConstraint {
  pathPattern?: string[];       // ["/tmp/**", "/data/readonly/**"]
  networkHosts?: string[];      // ["api.example.com"]
  envKeyPattern?: string[];     // ["APP_*", "!APP_SECRET_*"]
  maxPayloadBytes?: number;
}

플러그인은 배포 시점에 plugin.manifest.yaml로 필요한 스코프를 명시한다. 예시로 로그 파서 플러그인은 특정 경로의 로그 파일 읽기와 내부 메트릭 서버 접근만 필요하다.

# plugin.manifest.yaml
name: log-parser
version: 1.2.0
scopes:
  - resource: file
    actions: [read]
    constraints:
      pathPattern:
        - "/var/log/app/*.log"
  - resource: network
    actions: [read]
    constraints:
      networkHosts:
        - "metrics.internal"

매니페스트를 파싱할 때 허용되지 않은 리소스 타입이나 pathPattern 없이 file 스코프를 선언하는 경우 배포 자체를 거부한다. 권한 축소 압력을 배포 파이프라인에 내재화하는 첫 번째 지점이다.


런타임 권한 게이트: 선언을 실제 검증으로

선언만으로는 아무것도 막지 못한다. 플러그인 코드가 실행될 때 모든 리소스 접근 요청을 인터셉트하는 게이트가 반드시 필요하다.

class PermissionGate {
  private grantedScopes: Map<string, PluginScope[]>;

  check(pluginId: string, request: ResourceRequest): boolean {
    const scopes = this.grantedScopes.get(pluginId) ?? [];
    return scopes.some(scope =>
      scope.resource === request.resource &&
      scope.actions.includes(request.action) &&
      this.matchesConstraints(scope.constraints, request)
    );
  }

  private matchesConstraints(
    constraints: ScopeConstraint,
    request: ResourceRequest
  ): boolean {
    if (constraints.pathPattern && request.path) {
      return constraints.pathPattern.some(p =>
        micromatch.isMatch(request.path!, p)
      );
    }
    if (constraints.networkHosts && request.host) {
      return constraints.networkHosts.includes(request.host);
    }
    return true;
  }
}

플러그인 실행 컨텍스트는 Worker thread, V8 isolate, Wasm sandbox 중 하나로 격리하고, 해당 컨텍스트에서 발생하는 모든 시스템 콜을 게이트를 통해 라우팅한다. 게이트를 우회하는 직접 I/O 호출은 샌드박스 자체가 차단한다.

에스컬레이션 차단 구조


권한 에스컬레이션 방지: 세 가지 벡터

게이트를 우회하려는 시도는 세 가지 패턴으로 나타난다.

첫째, 심볼릭 링크를 통한 간접 접근. 허용된 경로 안에 심볼릭 링크를 만들어 외부 경로에 접근하는 방식이다. 경로를 항상 정규화한 뒤 검증하면 막을 수 있다.

function resolveSafePath(base: string, input: string): string | null {
  const resolved = path.resolve(base, input);
  if (!resolved.startsWith(base)) return null; // path traversal 차단
  return resolved;
}

둘째, 플러그인 간 위임 체인. 플러그인 A가 플러그인 B를 호출해 B의 더 넓은 권한으로 리소스에 접근하는 패턴이다. 호출 스택에 원래 플러그인 ID를 전파하고, 교집합 권한만 허용한다.

function mergeScopes(
  callerScopes: PluginScope[],
  calleeScopes: PluginScope[]
): PluginScope[] {
  // 합집합이 아닌 교집합만 허용
  return callerScopes.filter(cs =>
    calleeScopes.some(ces =>
      ces.resource === cs.resource &&
      cs.actions.every(a => ces.actions.includes(a))
    )
  );
}

셋째, 환경변수 오염. process.env 전체를 플러그인에 노출하면 DB 비밀번호나 API 키가 그대로 보인다. 허용된 패턴만 포함한 스냅샷을 전달한다.

function filterEnv(
  env: NodeJS.ProcessEnv,
  patterns: string[]
): Record<string, string> {
  return Object.fromEntries(
    Object.entries(env).filter(([key]) =>
      patterns.some(p => micromatch.isMatch(key, p))
    )
  );
}

envKeyPattern!APP_SECRET_* 같은 부정 패턴을 지원하면 와일드카드를 허용하면서도 민감 키를 제외할 수 있다.


감사 로그와 스코프 드리프트 감지

권한 검증 결과를 구조화된 로그로 남기는 것이 세 번째 단계다.

interface AuditEntry {
  timestamp: number;
  pluginId: string;
  resource: string;
  action: string;
  path?: string;
  allowed: boolean;
  reason?: string; // "scope_not_found" | "constraint_mismatch"
}

30일치 로그에서 실제 접근 패턴과 선언된 스코프를 비교하면 두 가지 신호를 포착할 수 있다.

신호 의미 대응
선언했지만 미사용 스코프 과잉 권한 다음 배포 시 제거 권고
allowed: false 반복 버그 또는 악의적 시도 즉시 알림 + 플러그인 격리

# 30일간 미사용 스코프 분석
jq '
  group_by(.pluginId) |
  map({
    plugin: .[0].pluginId,
    used_scopes: (
      [.[] | select(.allowed) | "\(.resource):\(.action)"] |
      unique
    )
  })
' audit.jsonl

선언-검증-감사 피드백 루프


PR 단계에서 스코프 크리프 차단

plugin.manifest.yaml이 변경될 때 자동으로 스코프 diff를 생성하고 리뷰어에게 알린다. 새로운 리소스 타입이나 확장된 pathPattern이 추가된 경우 보안 팀 승인을 필수로 설정하면 스코프 증가를 구조적으로 차단할 수 있다.

# .github/workflows/scope-review.yml
on:
  pull_request:
    paths:
      - "**/plugin.manifest.yaml"

jobs:
  scope-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 2 }
      - run: |
          git diff HEAD~1 -- '**/plugin.manifest.yaml' \
            | python scripts/scope_diff_report.py \
            | gh pr comment --body-file -

이 워크플로가 없으면 스코프는 거의 항상 증가하는 방향으로만 움직인다. 배포 편의를 위해 넓게 열어둔 권한은 좁혀지는 일이 없다. 자동화된 diff 리포트가 "이번 PR에서 스코프가 어떻게 바뀌었는가"를 가시화해야 리뷰어가 검토할 수 있다.


스코프 설계의 실질적인 효과는 선언-검증-감사 세 단계가 자동화된 루프를 형성할 때 나온다. 권한을 한 번 부여하면 줄어드는 일이 없다는 관성을 감사 로그 기반 피드백 루프로 깨는 것이 핵심이다. 다음 글에서는 플러그인 간 격리 수준을 결정하는 샌드박스 런타임 선택 기준(Worker thread vs V8 isolate vs Wasm)을 다룬다.


🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: 전체 글 목록
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독