Express 프록시 레이어에서 파일 다운로드 자동 암호화 구현하기

http-proxy-middleware의 selfHandleResponse 옵션을 활용해 파일 다운로드 응답을 가로채고 AES-256-GCM으로 암호화하는 방법. 전략 패턴으로 사내 API 연동도 준비.

1. 문제 상황

요구사항

셀프 호스팅 중인 워크플로우 자동화 도구에서 다음 보안 요구사항이 발생했습니다:

  • 파일 다운로드 시 자동 암호화: 사용자가 워크플로우에서 파일을 다운로드할 때 자동으로 암호화
  • 원본 서비스 코드 수정 불가: 오픈소스 도구를 사용 중이라 업스트림 업데이트 호환성 유지 필요
  • 사내 암호화 API 연동 준비: 현재는 자체 암호화, 추후 사내 보안 API로 전환 가능해야 함

기존 아키텍처

[사용자 브라우저] ──────────────────▶ [워크플로우 서비스]
                    직접 연결
                    파일이 평문으로 다운로드됨

문제점

  1. 민감한 파일이 암호화 없이 전송됨
  2. 원본 서비스를 수정하면 업데이트 시 충돌 발생
  3. 향후 사내 암호화 API로 전환 시 대규모 수정 필요

2. 해결 방법: 프록시 레이어 암호화

프록시 레이어란?

프록시 레이어는 클라이언트와 서버 사이에서 요청/응답을 중계하는 중간 서버입니다. 이 위치에서 다양한 부가 기능을 투명하게 추가할 수 있습니다.

[사용자 브라우저] ──▶ [프록시 서버] ──▶ [워크플로우 서비스]
                          ↑
                      여기서 가로채서
                      - 인증 검사
                      - 로깅
                      - 암호화  ← 이번 구현
                      - 요청/응답 수정

왜 프록시 레이어인가?

장점 설명
원본 코드 수정 불필요 워크플로우 서비스 업데이트해도 영향 없음
관심사 분리 인증, 암호화 로직을 별도 서비스로 관리
유연성 환경변수로 암호화 방식 전환 가능
테스트 용이 프록시만 따로 테스트 가능

아키텍처 설계

┌─────────────┐      ┌──────────────────────┐      ┌─────────────┐
│   사용자     │ ──▶ │    Auth Gateway      │ ──▶ │  워크플로우  │
│  브라우저    │      │     (프록시)          │      │   서비스    │
└─────────────┘      └──────────────────────┘      └─────────────┘
       │                      │                          │
       │  1. 다운로드 요청     │                          │
       │ ────────────────────▶│  2. 요청 전달             │
       │                      │ ────────────────────────▶│
       │                      │                          │
       │                      │  3. 원본 파일 응답        │
       │                      │◀──────────────────────── │
       │                      │                          │
       │                      │  4. 암호화 수행           │
       │                      │  ┌─────────────┐         │
       │                      │  │ AES-256-GCM │         │
       │                      │  └─────────────┘         │
       │                      │                          │
       │  5. 암호화된 파일     │                          │
       │◀──────────────────── │                          │

3. 핵심 구현

3.1 프로젝트 구조

auth-gateway/
├── src/
│   ├── index.js          # Express 앱 + 프록시 설정
│   ├── encryption.js     # 암호화 모듈 (전략 패턴)
│   ├── auth.js           # 인증 모듈
│   └── public/
│       └── decrypt.html  # 복호화 웹 페이지
├── package.json
└── Dockerfile

3.2 암호화 모듈 (전략 패턴)

암호화 방식을 쉽게 전환할 수 있도록 전략 패턴을 적용합니다.

// src/encryption.js
const crypto = require('crypto');
const axios = require('axios');

// 환경변수로 암호화 방식 선택
const USE_ENCRYPTION_API = process.env.USE_ENCRYPTION_API === 'true';
const ENCRYPTION_API_URL = process.env.ENCRYPTION_API_URL || '';
const ENCRYPTION_API_KEY = process.env.ENCRYPTION_API_KEY || '';
const FILE_ENCRYPTION_KEY = process.env.FILE_ENCRYPTION_KEY || 'default-32-char-encryption-key!';
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';

/**
 * 자체 암호화 (AES-256-GCM)
 *
 * 암호화된 데이터 형식:
 * [IV 16bytes][AuthTag 16bytes][암호화된 데이터]
 */
function encryptLocal(buffer) {
  const iv = crypto.randomBytes(16);  // ← 매번 새로운 IV 생성 (보안상 필수)
  const key = Buffer.from(FILE_ENCRYPTION_KEY.padEnd(32).slice(0, 32));
  const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);

  const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
  const authTag = cipher.getAuthTag();  // ← GCM 모드의 인증 태그

  // IV + AuthTag + 암호화된 데이터를 하나의 버퍼로 결합
  return Buffer.concat([iv, authTag, encrypted]);
}

/**
 * 자체 복호화 (AES-256-GCM)
 */
function decryptLocal(buffer) {
  const iv = buffer.slice(0, 16);          // ← IV 추출
  const authTag = buffer.slice(16, 32);    // ← AuthTag 추출
  const encrypted = buffer.slice(32);       // ← 암호화된 데이터

  const key = Buffer.from(FILE_ENCRYPTION_KEY.padEnd(32).slice(0, 32));
  const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
  decipher.setAuthTag(authTag);  // ← AuthTag 설정 (무결성 검증)

  return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}

/**
 * 사내 API 암호화 (추후 연동)
 */
async function encryptWithAPI(buffer, filename) {
  if (!ENCRYPTION_API_URL) {
    throw new Error('ENCRYPTION_API_URL이 설정되지 않았습니다.');
  }

  const response = await axios.post(
    `${ENCRYPTION_API_URL}/encrypt`,
    buffer,
    {
      headers: {
        'Content-Type': 'application/octet-stream',
        'X-API-Key': ENCRYPTION_API_KEY,
        'X-Original-Filename': filename,
      },
      responseType: 'arraybuffer',
      timeout: 30000,
    }
  );

  return Buffer.from(response.data);
}

/**
 * 암호화 함수 (자동 선택)
 * 환경변수에 따라 자체 암호화 또는 API 암호화 선택
 */
async function encrypt(buffer, filename = 'file') {
  if (USE_ENCRYPTION_API) {
    console.log('[Encryption] Using internal API');
    return await encryptWithAPI(buffer, filename);
  } else {
    console.log('[Encryption] Using local AES-256-GCM');
    return encryptLocal(buffer);
  }
}

/**
 * 현재 암호화 방식 확인
 */
function getEncryptionMode() {
  return {
    useAPI: USE_ENCRYPTION_API,
    apiConfigured: !!ENCRYPTION_API_URL,
    algorithm: USE_ENCRYPTION_API ? 'Internal API' : ENCRYPTION_ALGORITHM,
  };
}

module.exports = {
  encrypt,
  decrypt,
  encryptLocal,
  decryptLocal,
  getEncryptionMode,
};

3.3 파일 다운로드 감지 함수

어떤 응답이 파일 다운로드인지 판단하는 함수입니다.

// src/index.js

/**
 * Content-Type과 Content-Disposition으로 파일 다운로드 여부 판단
 */
function isDownloadableFile(contentType, contentDisposition) {
  // attachment 헤더가 있으면 파일 다운로드
  if (contentDisposition && contentDisposition.includes('attachment')) {
    return true;
  }

  // Content-Type으로 파일 여부 판단
  const fileTypes = [
    'application/octet-stream',
    'application/pdf',
    'application/zip',
    'application/x-zip-compressed',
    'application/vnd.ms-excel',
    'application/vnd.openxmlformats-officedocument',
    'application/msword',
    'image/',
    'video/',
    'audio/',
  ];

  return fileTypes.some(type => contentType && contentType.includes(type));
}

3.4 암호화 프록시 미들웨어 (핵심)

http-proxy-middlewareselfHandleResponse 옵션을 사용해 응답을 직접 처리합니다.

// src/index.js
const { createProxyMiddleware } = require('http-proxy-middleware');
const encryption = require('./encryption');

const FILE_ENCRYPTION_ENABLED = process.env.FILE_ENCRYPTION_ENABLED !== 'false';
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:8080';

/**
 * 파일 다운로드 암호화 프록시
 *
 * selfHandleResponse: true로 설정하면 프록시가 응답을 자동으로
 * 클라이언트에 전달하지 않고, 우리가 직접 처리할 수 있습니다.
 */
const encryptedFileProxy = createProxyMiddleware({
  target: BACKEND_URL,
  changeOrigin: true,
  selfHandleResponse: true,  // ← 핵심: 응답을 직접 처리

  // 요청 전달 시 쿠키 포워딩
  onProxyReq: (proxyReq, req, res) => {
    if (req.cookies['session-token']) {
      proxyReq.setHeader('Cookie', `session-token=${req.cookies['session-token']}`);
    }
  },

  // 응답 처리 (암호화 적용)
  onProxyRes: (proxyRes, req, res) => {
    const contentType = proxyRes.headers['content-type'] || '';
    const contentDisposition = proxyRes.headers['content-disposition'] || '';

    // 파일 다운로드가 아니면 그대로 전달
    if (!FILE_ENCRYPTION_ENABLED || !isDownloadableFile(contentType, contentDisposition)) {
      res.writeHead(proxyRes.statusCode, proxyRes.headers);
      proxyRes.pipe(res);  // ← 스트리밍으로 바로 전달
      return;
    }

    // 파일 데이터 수집 (버퍼링)
    const chunks = [];
    proxyRes.on('data', chunk => chunks.push(chunk));

    proxyRes.on('end', async () => {
      try {
        const originalData = Buffer.concat(chunks);

        // 원본 파일명 추출
        let originalFilename = 'file';
        const filenameMatch = contentDisposition.match(
          /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
        );
        if (filenameMatch) {
          originalFilename = filenameMatch[1].replace(/['"]/g, '');
        }

        // 암호화 수행
        const encryptedData = await encryption.encrypt(originalData, originalFilename);
        const encryptionMode = encryption.getEncryptionMode();

        // 암호화된 파일로 응답
        res.setHeader('Content-Type', 'application/octet-stream');
        res.setHeader('Content-Disposition',
          `attachment; filename="${originalFilename}.enc"`);  // ← .enc 확장자 추가
        res.setHeader('Content-Length', encryptedData.length);
        res.setHeader('X-Encrypted', 'true');
        res.setHeader('X-Original-Filename', originalFilename);
        res.setHeader('X-Encryption-Mode', encryptionMode.useAPI ? 'api' : 'local');

        res.status(proxyRes.statusCode).send(encryptedData);

        console.log(`[Encryption] ${originalFilename} -> ${originalFilename}.enc ` +
          `(${originalData.length} -> ${encryptedData.length} bytes)`);
      } catch (err) {
        console.error('[Encryption] Error:', err.message);
        res.status(500).json({ error: 'File encryption failed' });
      }
    });
  },

  onError: (err, req, res) => {
    console.error('Proxy error:', err.message);
    if (!res.headersSent) {
      res.status(502).json({ error: 'Proxy error' });
    }
  },
});

// 파일 다운로드 경로에 암호화 프록시 적용
app.use('/rest/binary-data', encryptedFileProxy);

3.5 일반 프록시 (암호화 불필요한 요청)

파일 다운로드가 아닌 일반 요청은 그대로 전달합니다.

// src/index.js

/**
 * 일반 프록시 - 암호화 없이 요청/응답 전달
 */
const generalProxy = createProxyMiddleware({
  target: BACKEND_URL,
  changeOrigin: true,
  ws: true,  // WebSocket 지원
  timeout: 300000,
  proxyTimeout: 300000,

  onProxyReq: (proxyReq, req, res) => {
    if (req.cookies['session-token']) {
      proxyReq.setHeader('Cookie', `session-token=${req.cookies['session-token']}`);
    }
  },

  onProxyRes: (proxyRes, req, res) => {
    // 로그인 페이지 리다이렉트 변환
    const location = proxyRes.headers['location'];
    if (location && location.includes('/signin')) {
      proxyRes.headers['location'] = '/login';
    }
  },

  onError: (err, req, res) => {
    console.error('Proxy error:', err.message);
    if (!res.headersSent) {
      res.status(502).json({ error: '서버에 연결할 수 없습니다.' });
    }
  },
});

// 나머지 모든 요청에 일반 프록시 적용
app.use('/', generalProxy);

3.6 복호화 API

서버에서 복호화를 수행하는 API입니다. 특히 사내 API 연동 시 클라이언트에서 직접 복호화할 수 없으므로 서버 API가 필요합니다.

// src/index.js
const multer = require('multer');
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 100 * 1024 * 1024 }  // 100MB 제한
});

/**
 * 암호화 상태 확인 API
 */
app.get('/api/encryption/status', (req, res) => {
  const mode = encryption.getEncryptionMode();
  res.json({
    enabled: FILE_ENCRYPTION_ENABLED,
    mode: mode.useAPI ? 'api' : 'local',
    algorithm: mode.algorithm,
    apiConfigured: mode.apiConfigured,
  });
});

/**
 * 복호화 API
 * 암호화된 파일을 업로드하면 복호화된 파일을 반환
 */
app.post('/api/decrypt', upload.single('file'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: '파일이 필요합니다.' });
    }

    const encryptedData = req.file.buffer;
    const originalFilename = req.file.originalname.replace(/\.enc$/, '');

    // 복호화 수행
    const decryptedData = await encryption.decrypt(encryptedData, originalFilename);

    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename="${originalFilename}"`);
    res.setHeader('Content-Length', decryptedData.length);
    res.send(decryptedData);

    console.log(`[Decryption] ${req.file.originalname} -> ${originalFilename}`);
  } catch (err) {
    console.error('[Decryption] Error:', err.message);
    res.status(500).json({ error: '복호화 실패. 키가 올바른지 확인하세요.' });
  }
});

3.7 클라이언트 복호화 페이지

로컬 암호화 모드에서는 클라이언트에서도 복호화할 수 있습니다. Web Crypto API를 사용합니다.

<!-- src/public/decrypt.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>파일 복호화</title>
</head>
<body>
  <div class="container">
    <h1>파일 복호화</h1>
    <input type="file" id="fileInput" accept=".enc">
    <input type="password" id="encryptionKey" placeholder="암호화 키">
    <button id="decryptBtn">복호화</button>
  </div>

  <script>
    async function decryptLocal(file, key) {
      const arrayBuffer = await file.arrayBuffer();
      const encryptedData = new Uint8Array(arrayBuffer);

      // 데이터 파싱: [IV 16bytes][AuthTag 16bytes][암호화된 데이터]
      const iv = encryptedData.slice(0, 16);
      const authTag = encryptedData.slice(16, 32);
      const ciphertext = encryptedData.slice(32);

      // 키 준비 (32 bytes)
      const keyString = key.padEnd(32).slice(0, 32);
      const keyData = new TextEncoder().encode(keyString);

      // Web Crypto API로 복호화
      const cryptoKey = await crypto.subtle.importKey(
        'raw',
        keyData,
        { name: 'AES-GCM' },
        false,
        ['decrypt']
      );

      // Web Crypto는 AuthTag를 ciphertext 뒤에 붙여야 함
      const ciphertextWithTag = new Uint8Array(ciphertext.length + authTag.length);
      ciphertextWithTag.set(ciphertext);
      ciphertextWithTag.set(authTag, ciphertext.length);

      const decrypted = await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv: iv },
        cryptoKey,
        ciphertextWithTag
      );

      return decrypted;
    }

    document.getElementById('decryptBtn').addEventListener('click', async () => {
      const file = document.getElementById('fileInput').files[0];
      const key = document.getElementById('encryptionKey').value;

      if (!file || !key) {
        alert('파일과 키를 입력하세요.');
        return;
      }

      try {
        const decrypted = await decryptLocal(file, key);

        // 다운로드
        const blob = new Blob([decrypted]);
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = file.name.replace(/\.enc$/, '');
        a.click();
        URL.revokeObjectURL(url);
      } catch (err) {
        alert('복호화 실패: ' + err.message);
      }
    });
  </script>
</body>
</html>

4. Docker 구성

docker-compose.yml

version: '3.8'

services:
  auth-gateway:
    build:
      context: ./auth-gateway
      dockerfile: Dockerfile
    ports:
      - "3080:3080"
    environment:
      - PORT=3080
      - BACKEND_URL=http://backend:8080
      - NODE_ENV=production
      # 파일 암호화 설정
      - FILE_ENCRYPTION_ENABLED=true
      - FILE_ENCRYPTION_KEY=${FILE_ENCRYPTION_KEY:-your-32-char-secret-key-here!!}
      # 사내 API 연동 (추후 설정)
      - USE_ENCRYPTION_API=${USE_ENCRYPTION_API:-false}
      - ENCRYPTION_API_URL=${ENCRYPTION_API_URL:-}
      - ENCRYPTION_API_KEY=${ENCRYPTION_API_KEY:-}
    depends_on:
      - backend
    healthcheck:
      test: ['CMD-SHELL', 'wget --spider -q http://localhost:3080/health || exit 1']
      interval: 30s
      timeout: 10s
      retries: 3

  backend:
    image: your-backend-image:latest
    # 외부 포트 노출 제거 - auth-gateway를 통해서만 접근
    # ports:
    #   - 8080:8080

Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY src ./src

EXPOSE 3080

CMD ["node", "src/index.js"]

package.json

{
  "name": "auth-gateway",
  "version": "1.0.0",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "axios": "^1.6.0",
    "cookie-parser": "^1.4.6",
    "http-proxy-middleware": "^3.0.0",
    "multer": "^1.4.5-lts.1"
  }
}

5. 환경변수 설정

.env 파일

# 파일 암호화 활성화 (기본: true)
FILE_ENCRYPTION_ENABLED=true

# 암호화 키 (32자)
# 운영 환경에서는 반드시 안전한 키로 변경!
FILE_ENCRYPTION_KEY=your-secure-32-character-key!!

# 사내 API 연동 (기본: false)
USE_ENCRYPTION_API=false

# 사내 API URL (USE_ENCRYPTION_API=true일 때 필요)
ENCRYPTION_API_URL=

# 사내 API 키
ENCRYPTION_API_KEY=

암호화 방식 전환

# 자체 암호화 (기본)
USE_ENCRYPTION_API=false

# 사내 API 연동
USE_ENCRYPTION_API=true
ENCRYPTION_API_URL=http://internal-encryption-api/v1
ENCRYPTION_API_KEY=your-api-key

6. 핵심 개념 정리

AES-256-GCM이란?

항목 설명
AES Advanced Encryption Standard, 대칭키 암호화 표준
256 256비트(32바이트) 키 사용
GCM Galois/Counter Mode, 인증된 암호화 모드
IV Initialization Vector, 매 암호화마다 고유해야 함
AuthTag 인증 태그, 데이터 무결성 검증용

암호화된 파일 형식

┌─────────────────────────────────────────────────────┐
│  IV (16 bytes)  │  AuthTag (16 bytes)  │  암호화 데이터  │
└─────────────────────────────────────────────────────┘
     ↑                    ↑                    ↑
   복호화에 필요      무결성 검증에 필요      실제 암호화된 내용

selfHandleResponse 옵션

동작
false (기본) 프록시가 응답을 자동으로 클라이언트에 전달
true 프록시가 응답을 전달하지 않음, 직접 처리 필요

selfHandleResponse: true를 사용하면 백엔드 응답을 버퍼링하고 가공한 후 클라이언트에 전달할 수 있습니다.


7. 베스트 프랙티스

보안 체크리스트

  • [ ] 암호화 키 관리: 환경변수로 주입, 코드에 하드코딩 금지
  • [ ] 키 길이: AES-256은 정확히 32바이트 키 필요
  • [ ] IV 재사용 금지: 매 암호화마다 crypto.randomBytes(16) 사용
  • [ ] GCM 모드 사용: CBC 대신 GCM으로 무결성 검증
  • [ ] 에러 메시지: 복호화 실패 시 상세 정보 노출 금지

성능 고려사항

// 대용량 파일 처리 시 주의
// 현재 구현은 전체 파일을 메모리에 로드

// 개선 방안 1: 스트리밍 암호화
const { createCipheriv } = require('crypto');
const cipher = createCipheriv('aes-256-gcm', key, iv);
proxyRes.pipe(cipher).pipe(res);

// 개선 방안 2: 파일 크기 제한
if (originalData.length > 100 * 1024 * 1024) {  // 100MB
  // 대용량 파일은 암호화 스킵 또는 스트리밍 처리
}

확장성 패턴

// 전략 패턴으로 암호화 방식 추가 용이
const encryptionStrategies = {
  local: encryptLocal,
  internalAPI: encryptWithAPI,
  aws: encryptWithAWSKMS,      // 추가 가능
  azure: encryptWithAzureKV,   // 추가 가능
};

async function encrypt(buffer, filename) {
  const strategy = process.env.ENCRYPTION_STRATEGY || 'local';
  return encryptionStrategies[strategy](buffer, filename);
}

8. FAQ

Q: 왜 CBC 대신 GCM을 사용하나요?

A: GCM(Galois/Counter Mode)은 암호화와 동시에 인증(무결성 검증)을 제공합니다. CBC는 별도의 HMAC이 필요하지만, GCM은 AuthTag로 데이터 변조를 감지할 수 있어 더 안전하고 구현이 간단합니다.

Q: IV는 왜 매번 새로 생성해야 하나요?

A: 같은 키로 같은 IV를 재사용하면 암호문 패턴이 노출되어 보안이 취약해집니다. crypto.randomBytes(16)으로 매번 새로운 16바이트 IV를 생성해야 합니다.

Q: 클라이언트에서 복호화하면 키가 노출되지 않나요?

A: 로컬 암호화 모드에서 클라이언트 복호화를 사용하면 사용자가 키를 알아야 합니다. 더 높은 보안이 필요하면 USE_ENCRYPTION_API=true로 서버 측 복호화를 사용하세요.

Q: 대용량 파일은 어떻게 처리하나요?

A: 현재 구현은 파일 전체를 메모리에 로드합니다. 100MB 이상의 파일은 스트리밍 암호화를 고려하거나 파일 크기 제한을 설정하세요.

Q: 암호화 키를 변경하면 기존 파일은?

A: 이전 키로 암호화된 파일은 복호화할 수 없습니다. 키 변경 전 모든 파일을 복호화하거나, 키 버전 관리 시스템을 구현해야 합니다.


9. 참고 자료