Express 프록시 레이어에서 파일 다운로드 자동 암호화 구현하기
http-proxy-middleware의 selfHandleResponse 옵션을 활용해 파일 다운로드 응답을 가로채고 AES-256-GCM으로 암호화하는 방법. 전략 패턴으로 사내 API 연동도 준비.
1. 문제 상황
요구사항
셀프 호스팅 중인 워크플로우 자동화 도구에서 다음 보안 요구사항이 발생했습니다:
- 파일 다운로드 시 자동 암호화: 사용자가 워크플로우에서 파일을 다운로드할 때 자동으로 암호화
- 원본 서비스 코드 수정 불가: 오픈소스 도구를 사용 중이라 업스트림 업데이트 호환성 유지 필요
- 사내 암호화 API 연동 준비: 현재는 자체 암호화, 추후 사내 보안 API로 전환 가능해야 함
기존 아키텍처
[사용자 브라우저] ──────────────────▶ [워크플로우 서비스]
직접 연결
파일이 평문으로 다운로드됨
문제점
- 민감한 파일이 암호화 없이 전송됨
- 원본 서비스를 수정하면 업데이트 시 충돌 발생
- 향후 사내 암호화 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-middleware의 selfHandleResponse 옵션을 사용해 응답을 직접 처리합니다.
// 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: 이전 키로 암호화된 파일은 복호화할 수 없습니다. 키 변경 전 모든 파일을 복호화하거나, 키 버전 관리 시스템을 구현해야 합니다.