Devin.KR

운영 보안 기본 - 입력 한도·권한·비밀값

개발자KR 조회 16

이 장에서 배우는 것

앞 장에서 요청의 흐름을 기록하고 서버 상태를 관찰했다. 이제 그 기록을 남기는 서버 자체에 운영 경계를 세운다. 로그 수집 서버는 외부 입력을 받아 파일을 만들고, 실행 환경에서 인증 정보를 읽는다. 요청 하나가 지나치게 크거나 파일 이름이 저장 디렉터리를 벗어나거나 인증 정보가 로그에 섞이면, 기능이 정상적으로 동작해도 운영상 문제가 생긴다.

이 장에서는 접속 기록을 받는 작은 서버에 입력 크기, 파일 경로, 기록할 정보의 경계를 더한다. 여기에 의존성 관리와 실행 사용자 권한을 연결한다. 예제는 Node.js 22의 표준 모듈만 사용하는 ESM 프로그램이다.

  • 본문을 받는 도중 실제 바이트 수를 세고 한도를 넘으면 요청을 거절한다.
  • path.resolve로 정규화한 경로를 검사하고 심볼릭 링크에 대한 별도 방어를 적용한다.
  • 허용한 필드만 저장하고 비밀값을 운영 로그에서 제외한다.
  • npm audit의 범위를 이해하고 의존성을 줄이는 기준을 세운다.
  • 일반 실행 사용자에게 필요한 디렉터리의 쓰기 권한만 부여한다.

문제 상황

로그 수집 서버는 처음에는 팀 내부에서만 사용됐다. 클라이언트가 파일 이름과 접속 기록을 보내면 서버는 지정한 파일에 한 줄씩 덧붙였다. 개발 중에는 요청 객체 전체를 출력해도 편리했고, 저장 경로를 문자열로 이어 붙여도 문제가 드러나지 않았다. 파일 쓰기 오류가 생기자 관리자 권한으로 서버를 실행해 해결하기도 했다.

운영에서는 이 선택들이 서로 영향을 준다. 잘못 설정한 클라이언트가 수십 메가바이트의 본문을 보내면 문자열을 이어 붙이는 동안 메모리가 증가한다. 파일 이름으로 ../outside.ndjson을 받으면 의도한 디렉터리 밖에 기록할 수 있다. 요청 헤더를 통째로 출력하면 인증 토큰이 중앙 로그 저장소까지 전달된다. 이때 서버가 관리자 권한으로 실행 중이라면 잘못된 파일 접근이 닿을 수 있는 범위도 넓어진다.

이번 예제의 계약은 작게 잡는다. POST /logs 요청은 이름이 정해진 형식의 파일에 접속 기록 하나를 추가한다. 본문은 최대 1,024바이트이며 route와 status만 받는다. 클라이언트가 사용할 인증 토큰은 환경 변수에서 읽는다. 예제 주소는 로컬 시험용으로 127.0.0.1에만 바인딩한다. 실제 외부 전송에서 토큰을 보호하려면 암호화된 연결이 필요하며, 여기서는 로컬 요청으로 동작을 확인한다.

입력 한도는 읽는 도중에 적용한다

본문 크기 제한은 파싱 이전의 경계다. JSON.parse를 호출한 뒤 객체가 너무 크다고 판단하면 이미 본문 전체를 받아 보관한 상태다. 청크를 받을 때마다 바이트 수를 더하고, 누적 크기가 한도를 넘는 순간 수집을 멈춰야 한다. 이번 프로그램은 요청 스트림에 문자열 인코딩을 지정하지 않으므로 각 청크는 Buffer이며 length로 바이트 수를 얻는다.

Content-Length는 빠른 거절에 이용할 수 있다. 선언된 길이가 한도를 넘으면 본문을 모을 필요가 없다. 하지만 이 헤더가 없는 전송도 있으므로 헤더 검사만으로 제한을 구현하지 않는다. 본문을 읽는 동안에도 같은 한도를 적용한다. 문자열의 length는 바이트 수와 다르므로 한글이 들어간 요청에서 특히 혼동하기 쉽다.

한도를 넘으면 413으로 응답한다. 이때 요청의 나머지 본문을 계속 메모리에 모으지 않아야 한다. 예제는 요청 읽기를 멈추고 Connection: close를 붙여 응답한 뒤 연결을 닫는다. 요청을 먼저 파괴하면 응답이 도착하기 전에 연결이 끊길 수 있으므로, 크기 초과를 감지한 자리에서 곧바로 destroy를 호출하지 않는다.

본문은 청크마다 바이트 수를 검사하고 한도 이내인 경우에만 모아 파싱한다

이 제한이 프로세스 전체의 메모리를 1,024바이트로 묶는다는 뜻은 아니다. 한도를 넘긴 청크도 검사 시점에는 이미 전달된 상태이며, 네트워크 내부 버퍼와 파싱한 객체도 메모리를 사용한다. 동시 요청이 많아지면 각 요청의 사용량이 합쳐진다. 본문 한도는 요청 하나가 무제한으로 커지는 것을 막는 장치다. 느리게 보내는 요청의 시간 제한이나 전체 연결 수 제한까지 대신하지는 않는다.

입력 검사 위치에 따라 막을 수 있는 문제가 달라진다
검사 위치검사 대상실패 응답
본문 수집 전인증, 파일 이름, 본문 형식401, 400, 403, 415
본문 수집 중누적 바이트 수413
본문 수집 후JSON 문법과 허용 필드400
파일을 연 직후일반 파일인지 여부403

경로 검증과 실행 권한을 함께 제한한다

경로 조작은 외부에서 받은 이름이 의도하지 않은 파일 위치를 가리키게 만드는 문제다. path.resolve는 점과 상위 디렉터리 표시를 처리해 절대 경로를 만들지만, 결과가 허용된 위치인지 판단하지는 않는다. 따라서 정규화한 뒤 저장 루트 내부인지 별도로 검사한다.

단순히 target.startsWith(root)만 검사하면 형제 디렉터리까지 통과할 수 있다. 저장 루트가 /srv/log-data일 때 /srv/log-data-old도 같은 문자열로 시작한다. 예제는 root에 경로 구분자를 붙인 접두사를 사용한다. 이어서 파일 이름을 영문 소문자, 숫자, 밑줄, 하이픈과 .ndjson 확장자로 제한한다. 경계 검사는 위치를 확인하고, 이름 검사는 서비스가 제공할 파일 이름의 범위를 줄인다.

문자열 경로와 실제 파일 시스템의 위치는 구분해야 한다. 저장 디렉터리 안에 외부 파일을 가리키는 심볼릭 링크가 있으면 문자열 검사를 통과할 수 있다. 예제는 하위 디렉터리를 허용하지 않고 O_NOFOLLOW로 마지막 경로 요소의 링크를 따라가지 않도록 파일을 연다. 파일을 연 뒤에는 같은 파일 핸들로 일반 파일인지 검사한다.

이 조합도 신뢰할 수 없는 사용자가 저장 디렉터리와 상위 경로를 마음대로 교체할 수 있는 환경을 해결하지는 못한다. 하드 링크 역시 O_NOFOLLOW의 검사 대상이 아니다. 저장 디렉터리는 서비스 전용으로 준비하고 다른 사용자가 내용을 변경하지 못하게 해야 한다. 애플리케이션은 코드와 설정을 읽을 수 있어야 하지만, 코드 디렉터리에 쓸 필요는 없다.

경로 문자열 검사와 실제 파일 열기 검사는 서로 다른 경계를 확인한다

실행 사용자는 관리자 계정 대신 별도의 일반 계정을 사용한다. 이 계정에는 로그 저장 디렉터리의 쓰기 권한만 준다. 예제의 3000번 포트는 관리자 권한을 요구하는 낮은 번호의 포트가 아니다. 파일 접근 오류가 나면 실행 계정을 높이기보다 저장 위치와 소유자를 확인한다.

디렉터리의 0700은 소유자에게만 읽기, 쓰기, 탐색을 허용한다. 파일의 0600은 소유자에게만 읽기와 쓰기를 허용한다. 생성 시 지정한 모드는 기존 파일이나 디렉터리의 권한을 고치지 않는다. 그래서 예제에는 제한적인 생성 모드를 사용하면서도, 운영 전에 기존 저장소의 소유권과 권한을 확인해야 한다는 전제가 있다.

비밀값과 의존성의 노출 범위를 줄인다

로그의 비밀값 가림은 마지막 방어로 생각하는 편이 좋다. 요청 객체를 통째로 기록한 다음 password라는 키만 가리면 다른 이름의 토큰이나 중첩 객체를 놓치기 쉽다. 예제는 운영 로그에 event와 status만 기록한다. 시작할 때만 고정된 포트도 남긴다. 요청 헤더, 원문 본문, 쿼리 문자열, 오류 메시지는 이 기록 함수에 전달하지 않는다.

수집 대상인 접속 기록에도 같은 원칙을 적용한다. route는 실제 URL 대신 /, /health, /items 중 하나만 받는다. URL의 쿼리에는 토큰이나 개인 식별 정보가 들어갈 수 있기 때문이다. status는 정수 상태 코드만 허용하고 나머지 필드는 거절한다. 검증 후 새 객체를 만들어 저장하면 이후 입력 필드가 늘어나더라도 저장 범위가 저절로 넓어지지 않는다.

토큰은 환경 변수에서 읽고 시작 로그에 출력하지 않는다. 환경 변수 자체가 비밀 저장소를 대신하는 것은 아니다. 프로세스를 조사할 수 있는 사용자, 진단 도구, 실행 설정을 열람할 수 있는 사람에게 노출될 수 있다. 실제 토큰을 코드, 저장소, 작업 기록에 붙여 넣지 않고 해당 프로세스에 필요한 값만 전달한다.

의존성을 추가할 때는 패키지 하나의 기능뿐 아니라 함께 설치되는 하위 의존성과 설치 스크립트도 검토한다. 간단한 경로 검사와 파일 쓰기처럼 표준 모듈로 표현할 수 있는 기능은 패키지 없이 구현할 수 있다. 반대로 복잡한 보안 규약을 패키지 수를 줄인다는 이유만으로 직접 구현하는 것은 별개의 위험을 만든다. 최소화의 기준은 숫자 자체보다 관리해야 하는 코드와 책임을 줄이는 데 있다.

npm audit는 의존성 트리를 알려진 취약점 정보와 대조한다. 문제가 보고되면 설치 버전, 실제 사용 경로, 수정 버전의 변경 사항을 함께 확인한다. 결과가 없다는 것이 애플리케이션의 안전성을 입증하지는 않는다. 이번 프로그램처럼 외부 패키지가 없어도 Node.js 런타임과 운영체제의 보안 업데이트는 필요하다.

사실 확인에는 Node.js의 경로 해석 설명, 파일 시스템 플래그 설명, npm audit 설명을 참고할 수 있다.

완성 코드

다음 코드를 server.mjs로 저장한다. 저장 디렉터리는 현재 작업 디렉터리 아래의 data이며, 시작 시 한 번 실제 경로로 확정한다. 예제는 저장 디렉터리와 상위 경로를 다른 사용자가 변경할 수 없다는 조건으로 실행한다. 별도의 패키지 설치는 필요하지 않다.

server.mjs

import http from 'node:http';
import path from 'node:path';
import { mkdir, realpath, open } from 'node:fs/promises';
import { constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';

// [1] 설정과 생성 권한
process.umask(0o077);
const HOST = '127.0.0.1';
const PORT = 3000;
const MAX_BODY = 1024;
const token = process.env.LOG_TOKEN;

if (!token || !/^[a-f0-9]{64}$/.test(token)) {
  console.error('LOG_TOKEN must be 64 lowercase hex characters');
  process.exit(1);
}

const expectedAuth = Buffer.from(`Bearer ${token}`);
const directory = path.resolve('data');
await mkdir(directory, { recursive: true, mode: 0o700 });
const root = await realpath(directory);
const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
const routes = new Set(['/', '/health', '/items']);

// [2] 외부 입력을 포함하지 않는 오류와 운영 로그
class RequestError extends Error {
  constructor(status, code) {
    super(code);
    this.status = status;
    this.code = code;
  }
}

function writeLog(event, status) {
  console.log(JSON.stringify({ event, status }));
}

function reply(res, status, value) {
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Connection': 'close',
  });
  res.end(JSON.stringify(value) + '\n');
}

// [3] 인증과 저장 대상 검사
function authenticate(req) {
  const supplied = Buffer.from(req.headers.authorization ?? '');
  if (
    supplied.length !== expectedAuth.length ||
    !timingSafeEqual(supplied, expectedAuth)
  ) {
    throw new RequestError(401, 'unauthorized');
  }
}

function resolveTarget(name) {
  if (typeof name !== 'string') {
    throw new RequestError(400, 'invalid_name');
  }

  const target = path.resolve(root, name);
  if (!target.startsWith(rootPrefix)) {
    throw new RequestError(403, 'path_forbidden');
  }
  if (!/^[a-z0-9][a-z0-9_-]{0,31}\.ndjson$/.test(name)) {
    throw new RequestError(400, 'invalid_name');
  }
  return target;
}

// [4] 파싱 전에 실제 바이트 수 제한
function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let total = 0;
    let settled = false;

    function fail(error) {
      if (settled) return;
      settled = true;
      chunks.length = 0;
      req.pause();
      reject(error);
    }

    req.on('data', (chunk) => {
      if (settled) return;
      total += chunk.length;
      if (total > MAX_BODY) {
        fail(new RequestError(413, 'body_too_large'));
        return;
      }
      chunks.push(chunk);
    });

    req.once('end', () => {
      if (settled) return;
      settled = true;
      const body = Buffer.concat(chunks, total);
      chunks.length = 0;
      resolve(body);
    });

    req.once('aborted', () => {
      fail(new RequestError(400, 'request_aborted'));
    });
    req.once('error', () => {
      fail(new RequestError(400, 'request_error'));
    });
  });
}

// [5] 허용 필드만 새 객체로 구성
function parseRecord(body) {
  let value;
  try {
    value = JSON.parse(body.toString('utf8'));
  } catch {
    throw new RequestError(400, 'invalid_json');
  }

  if (
    value === null ||
    typeof value !== 'object' ||
    Array.isArray(value) ||
    Object.keys(value).length !== 2 ||
    !routes.has(value.route) ||
    !Number.isInteger(value.status) ||
    value.status < 100 ||
    value.status > 599
  ) {
    throw new RequestError(400, 'invalid_record');
  }

  return { route: value.route, status: value.status };
}

// [6] 링크를 따라가지 않고 파일 핸들로 확인
async function appendRecord(target, record) {
  const flags =
    constants.O_WRONLY |
    constants.O_APPEND |
    constants.O_CREAT |
    constants.O_NOFOLLOW |
    constants.O_NONBLOCK;

  let file;
  try {
    file = await open(target, flags, 0o600);
    const stat = await file.stat();
    if (!stat.isFile()) {
      throw new RequestError(403, 'file_forbidden');
    }
    await file.writeFile(JSON.stringify(record) + '\n', 'utf8');
  } catch (error) {
    if (error.code === 'ELOOP' || error.code === 'EISDIR') {
      throw new RequestError(403, 'file_forbidden');
    }
    throw error;
  } finally {
    if (file) await file.close();
  }
}

// [7] 파일 작업을 한 프로세스 안에서 순서대로 실행
let pending = Promise.resolve();

function enqueueRecord(target, record) {
  const task = pending.then(() => appendRecord(target, record));
  pending = task.catch(() => {});
  return task;
}

// [8] 요청 처리 순서
async function handle(req, res) {
  authenticate(req);

  const url = new URL(req.url, 'http://localhost');
  if (req.method !== 'POST' || url.pathname !== '/logs') {
    throw new RequestError(404, 'not_found');
  }

  const names = url.searchParams.getAll('name');
  if (names.length !== 1) {
    throw new RequestError(400, 'invalid_name');
  }
  const target = resolveTarget(names[0]);

  const contentType = req.headers['content-type'] ?? '';
  if (contentType.split(';', 1)[0].trim().toLowerCase() !==
      'application/json') {
    throw new RequestError(415, 'json_required');
  }
  if (req.headers['content-encoding'] !== undefined) {
    throw new RequestError(415, 'encoding_not_supported');
  }
  if (Number(req.headers['content-length']) > MAX_BODY) {
    throw new RequestError(413, 'body_too_large');
  }

  const record = parseRecord(await readBody(req));
  await enqueueRecord(target, record);
  writeLog('stored', 201);
  reply(res, 201, { ok: true });
}

// [9] 오류의 외부 표현을 고정
const server = http.createServer((req, res) => {
  handle(req, res).catch((error) => {
    const known = error instanceof RequestError;
    const status = known ? error.status : 500;
    const code = known ? error.code : 'internal_error';

    req.pause();
    writeLog(known ? 'rejected' : 'failed', status);
    if (!res.destroyed && !res.writableEnded) {
      reply(res, status, { error: code });
    }
  });
});

server.on('error', () => {
  console.error(JSON.stringify({ event: 'server_error' }));
  process.exitCode = 1;
});

server.listen(PORT, HOST, () => {
  console.log(JSON.stringify({ event: 'listening', port: PORT }));
});

줄별 해설

[1] umask는 뒤에서 생성하는 파일과 디렉터리의 권한에서 허용하지 않을 비트를 제거한다. 기존 항목에는 영향을 주지 않는다. LOG_TOKEN은 형식을 검사한 뒤 인증 비교에 사용할 바이트로 만든다. realpath는 시작 시 저장 루트의 실제 위치를 얻는다. 이 위치를 확보했다고 해서 이후 디렉터리 교체까지 방지되는 것은 아니다.

[2] RequestError에는 프로그램이 정한 상태 코드와 고정된 오류 식별자만 넣는다. writeLog는 인자를 객체 전체로 받지 않으므로 요청 데이터가 실수로 함께 직렬화될 여지를 줄인다. reply는 응답 끝에 줄바꿈을 붙인다. 모든 응답에서 연결을 닫도록 한 것은 본문을 다 읽기 전에 거절하는 경로도 단순하게 처리하기 위한 예제의 선택이다.

[3] timingSafeEqual은 길이가 같은 바이트열을 비교하므로 길이를 먼저 검사한다. 이 함수 하나로 요청 처리 전체가 일정한 시간이 되는 것은 아니다. resolveTarget은 URLSearchParams가 한 번 해석한 이름을 받는다. 직접 decodeURIComponent를 다시 호출하지 않는다. 정규화한 위치를 확인한 다음, 하위 디렉터리도 표현할 수 없는 파일 이름 형식을 적용한다.

[4] total에는 문자열 길이 대신 청크의 바이트 수를 더한다. 초과 청크는 chunks에 넣지 않는다. fail은 이미 모은 참조를 비우고 요청을 멈춘 뒤 거절한다. settled는 중단, 오류, 종료 이벤트가 이어져도 처리 결과를 다시 바꾸지 않게 한다. 정상 종료에만 Buffer.concat을 호출하며, 정확히 1,024바이트인 본문은 다음 검증 단계로 진행한다.

[5] JSON 문법 오류와 데이터 형식 오류를 구분한다. 객체가 아닌 값과 배열을 먼저 제외하고, 키의 개수와 두 필드의 값을 검사한다. 저장할 객체를 새로 만들기 때문에 입력 객체 전체가 파일로 흘러가지 않는다. 이번 데이터 형식은 값의 선택지가 작아 원문 URL이나 임의 메시지를 저장하지 않는다.

[6] O_NOFOLLOW는 마지막 경로 요소가 심볼릭 링크이면 열기를 거절한다. O_NONBLOCK은 예상치 못한 FIFO를 열 때 기다리는 상황을 피하는 데 사용한다. 이후 stat.isFile로 일반 파일만 받는다. 쓰기와 검사를 같은 파일 핸들에서 수행하고 finally에서 닫는다. 권한 부족이나 디스크 오류 같은 나머지 실패는 내부 오류로 전달한다.

[7] pending은 이 프로세스의 파일 추가 작업을 차례로 실행한다. 실패한 작업 때문에 다음 작업까지 멈추지 않도록 큐의 연결에는 오류를 소비한 Promise를 저장한다. 요청 처리자는 원래 task를 기다리므로 해당 요청의 실패는 그대로 받는다. 이 큐는 여러 프로세스 사이의 동시성을 해결하지 않으며 대기 건수 제한도 제공하지 않는다.

[8] 인증과 대상 검사를 먼저 끝내고 본문을 읽는다. 압축된 본문은 받지 않으므로 압축 해제 후 크기라는 추가 경계가 생기지 않는다. Content-Length가 없을 때 Number(undefined)는 NaN이 되어 선행 검사를 통과하지만, 실제 바이트 검사는 그대로 적용된다. 파일 쓰기가 성공한 뒤에만 201을 보낸다.

[9] 예상한 거절은 정해진 오류 코드로 응답하고, 그 밖의 실패는 internal_error로 통일한다. error.message나 error.stack을 클라이언트와 일반 운영 로그에 그대로 넣지 않는다. 실제 운영에서 원인 분류가 필요하면 오류 종류를 제한된 값으로 변환해 기록한다. 원문 요청을 출력하는 방식으로 진단 정보를 늘리지 않는다.

실행 결과

먼저 문법을 검사한다. ESM 파일은 별도의 컴파일 단계 없이 실행하며, --check가 성공하면 출력이 없다. 아래 토큰은 동작 확인에만 쓰는 공개된 예시 값이다. 실제 서비스에는 새로 생성한 토큰을 사용한다.

node --check server.mjs
export LOG_TOKEN=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
node server.mjs

시작 출력은 다음과 같다.

{"event":"listening","port":3000}

다른 터미널에서 같은 시험용 값을 설정한다. data/access.ndjson이 없는 새 작업 디렉터리에서 시작하고, 아래 요청을 순서대로 한 번씩 보낸다고 가정한다.

export LOG_TOKEN=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
curl -sS -w '%{http_code}\n' \
  -H "Authorization: Bearer $LOG_TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary '{"route":"/items","status":200}' \
  'http://127.0.0.1:3000/logs?name=access.ndjson'
{"ok":true}
201

다음 요청은 ../outside.ndjson을 파일 이름으로 보낸다. 슬래시를 인코딩해도 쿼리 값을 해석한 뒤 경로를 검사하므로 저장 루트 밖으로 나갈 수 없다.

curl -sS -w '%{http_code}\n' \
  -H "Authorization: Bearer $LOG_TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary '{"route":"/items","status":200}' \
  'http://127.0.0.1:3000/logs?name=..%2Foutside.ndjson'
{"error":"path_forbidden"}
403

본문 수집 중의 제한은 Content-Length 없이 청크 전송으로 확인한다. 아래 명령은 2,048바이트를 만들며, JSON 문법 검사에 도달하기 전에 크기 초과로 거절된다.

node --input-type=module -e "process.stdout.write('x'.repeat(2048))" |
curl --http1.1 -sS -w '%{http_code}\n' \
  -H "Authorization: Bearer $LOG_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Transfer-Encoding: chunked' \
  -H 'Content-Length:' \
  --data-binary @- \
  'http://127.0.0.1:3000/logs?name=access.ndjson'
{"error":"body_too_large"}
413

서버 터미널에는 시작 로그 뒤로 다음 세 줄이 추가된다. 인증 토큰과 전송한 원문은 포함되지 않는다.

{"event":"stored","status":201}
{"event":"rejected","status":403}
{"event":"rejected","status":413}

저장 파일에는 성공한 기록 하나만 남는다. 같은 정상 요청을 반복하면 같은 줄이 추가된다.

cat data/access.ndjson
{"route":"/items","status":200}

배포할 때는 서비스 전용 일반 계정으로 저장 디렉터리를 준비한다. 다음 명령은 그 계정이 작업 디렉터리에 디렉터리를 만들 수 있다는 전제다. 기존 data가 있다면 소유자가 맞는지 먼저 확인한다. chmod는 소유권을 바꾸지 않는다.

mkdir -p data
chmod 700 data

외부 패키지를 사용하는 별도 프로젝트에서는 package.json과 잠금 파일을 관리하고 다음처럼 검사한다. 이번 단일 파일 예제에는 검사할 외부 의존성 트리가 없다. audit 결과는 설치 버전과 취약점 정보에 따라 달라지므로 고정된 성공 출력을 제시하지 않는다.

npm audit
npm audit --omit=dev

첫 명령은 개발 의존성까지 포함해 확인하는 데 쓰고, 두 번째는 개발 의존성을 제외한 범위를 확인하는 데 쓴다. 개발 의존성도 빌드와 설치 과정에서 실행될 수 있으므로 두 번째 결과만 보고 첫 번째 검사를 생략하지 않는다.

실무에서 자주 틀리는 것

다 받은 뒤 문자열 길이로 제한한다

다음 코드는 본문 전체를 보관한 뒤에야 판단하고, 길이도 바이트가 아닌 문자열 기준이다.

let text = '';
for await (const chunk of req) text += chunk;
if (text.length > MAX_BODY) throw new Error('too large');

고친 코드는 청크를 보관하기 전에 바이트 수를 확인한다. 아래는 완성 코드의 data 처리 부분이며, fail은 읽기를 멈추고 응답 처리로 오류를 전달한다.

total += chunk.length;
if (total > MAX_BODY) {
  fail(new RequestError(413, 'body_too_large'));
  return;
}
chunks.push(chunk);

경로 접두사만 같으면 내부 파일로 취급한다

다음 검사는 저장 루트와 이름이 비슷한 형제 디렉터리도 허용한다.

const target = path.resolve(root, name);
if (!target.startsWith(root)) throw new Error('forbidden');

구분자가 포함된 경계를 검사하고 허용할 이름 형식을 좁힌다. 이것은 문자열 경계 검사다. 완성 코드의 파일 열기 검사와 디렉터리 권한도 함께 필요하다.

const prefix = root.endsWith(path.sep) ? root : root + path.sep;
const target = path.resolve(root, name);
if (!target.startsWith(prefix)) {
  throw new RequestError(403, 'path_forbidden');
}
if (!/^[a-z0-9][a-z0-9_-]{0,31}\.ndjson$/.test(name)) {
  throw new RequestError(400, 'invalid_name');
}

요청을 출력하고 나중에 비밀값을 지운다

이 출력은 Authorization 헤더뿐 아니라 원문 본문까지 로그 저장소에 보낸다. 나중에 검색으로 지워도 이미 다른 시스템에 복제됐을 수 있다.

console.log(JSON.stringify({
  headers: req.headers,
  body: body.toString('utf8'),
}));

기록하려는 사건을 먼저 정의하고 필요한 고정 필드만 만든다. 오류를 처리할 때도 같은 원칙을 적용한다.

console.log(JSON.stringify({
  event: 'rejected',
  status: 413,
}));

권한 오류와 취약점 보고를 한 명령으로 덮는다

다음 명령을 기본 해결책으로 삼으면 실행 권한을 넓히거나 의존성의 큰 버전 변경을 충분한 검토 없이 적용할 수 있다.

sudo node server.mjs
npm audit fix --force

파일 오류는 일반 실행 계정의 저장소 권한을 확인해 해결한다. 취약점은 보고 내용을 읽고 필요한 버전을 선택해 변경한 뒤 검증한다. 아래 명령은 두 문제를 자동으로 고치는 묶음이 아니라, 각각 권한 확인 후 적용과 변경 전 조사에 해당한다.

chmod 700 data
node server.mjs
npm audit
npm outdated

의존성을 수정하면 잠금 파일의 차이를 확인하고 프로젝트의 테스트를 실행한다. 설치가 성공했다는 사실만으로 기존 동작이 유지된다고 판단하지 않는다.

한눈에 보기

로그 수집 서버의 운영 경계와 남는 책임
경계예제의 처리별도로 확인할 점
본문 크기수집 중 1,024바이트 제한동시 요청과 대기 작업 수
저장 위치정규화 후 구분자 경계 검사저장 루트와 상위 경로의 변경 권한
파일 종류링크 추적 거절, 일반 파일 확인신뢰할 수 없는 기존 파일과 하드 링크
기록 내용허용 필드만 새 객체로 저장새 필드 도입 시 비밀값 포함 여부
의존성표준 모듈 사용런타임 업데이트와 추가 패키지 검토
실행 권한제한적인 생성 모드 사용일반 실행 계정과 기존 파일의 권한

이번 서버의 인증 토큰은 수집 기능 전체를 사용할 권한을 준다. 사용자별 파일 소유권이나 저장 용량 할당은 구현하지 않는다. 본문이 작아도 요청을 반복하면 디스크가 찰 수 있으므로 요청 크기와 저장 총량은 다른 제한으로 다뤄야 한다. 다음 장에서는 이렇게 권한과 설정을 정한 프로세스를 배포하고 교체하는 흐름을 다룬다.

연습 문제

  1. MAX_BODY가 1,024일 때 본문이 정확히 1,024바이트인 경우와 1,025바이트인 경우는 어느 단계까지 진행하는가. Content-Length가 없을 때도 같은지 설명하라.
  2. 저장 루트가 /srv/log-data일 때 ../log-data-old/a.ndjson을 path.resolve에 전달한 결과를 구하라. root만 접두사로 검사하는 코드와 구분자를 붙인 코드의 판단을 비교하라.
  3. 클라이언트가 route와 status 외에 accessToken을 보내면 완성 코드가 어떻게 처리하는지 설명하라. 선택 필드인 durationMs를 추가한다면 무엇을 바꿔야 하는가.
  4. 저장 디렉터리가 이미 0777로 존재하고 서버가 관리자 계정으로 실행되고 있다. 코드의 mkdir과 umask만으로 이 상태가 고쳐지는지 설명하고, 변경할 운영 설정을 제시하라.

정답과 해설

  1. 1,024바이트는 크기 검사를 통과해 JSON 문법과 필드 검사를 받는다. 크기가 맞는다고 정상 기록인 것은 아니므로 문법이나 필드가 잘못되면 400이다. 1,025바이트는 413이다. Content-Length가 큰 값을 선언하면 수집 전에 거절하고, 헤더가 없으면 청크의 누적 크기를 검사하다 거절한다.
  2. 결과는 /srv/log-data-old/a.ndjson이다. 이 문자열은 /srv/log-data로 시작하므로 잘못된 접두사 검사를 통과한다. /srv/log-data/로는 시작하지 않으므로 수정한 검사에서 거절된다. 완성 코드는 이 단계에서 path_forbidden으로 응답한다.
  3. 키가 세 개이므로 invalid_record로 거절하며 파일에 저장하지 않는다. durationMs를 선택 필드로 받으려면 키 개수만 조정하지 말고 허용 키 집합을 검사해야 한다. 값이 있을 때 유한한 숫자인지와 허용 범위도 검사한다. 저장 객체에는 검증한 durationMs만 명시적으로 추가하고 운영 로그는 별도로 필요한지 판단한다.
  4. 고쳐지지 않는다. mkdir의 생성 모드는 기존 디렉터리를 변경하지 않으며 umask도 기존 권한을 줄이지 않는다. 관리 단계에서 저장소 소유자를 서비스 전용 일반 계정으로 맞추고 디렉터리와 기존 파일의 권한을 정리한다. 이후 그 일반 계정으로 실행한다. 저장소와 상위 경로를 다른 사용자가 변경할 수 없는지도 확인한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.