企业级 Web 应用安全部署实战指南
基于 Docker + Nginx + Spring Boot 的生产环境安全部署最佳实践
背景
在现代 Web 应用开发中,安全部署与功能开发同等重要。本文将分享一套完整的企业级应用安全部署方案,涵盖 API 访问控制、Referer 校验、限流防护等核心安全措施的实施细节。
适用场景:
- SPA(单页应用)架构的前后端分离项目
- 需要保护 API 不被外部直接调用的场景
- 使用 Docker 容器化部署的生产环境
架构概述
技术栈
| 层级 | 技术选型 |
|---|---|
| 前端 | Vue 3 + Vite |
| 管理后台 | Vue 3 + Element Plus |
| 后端 | Spring Boot 3.2 + MyBatis-Plus |
| 数据库 | MySQL 8.0 |
| 缓存 | Redis 7 |
| 反向代理 | Nginx |
| 容器化 | Docker Compose |
| SSL 证书 | 云服务商免费证书 |
部署架构
┌─────────────────────────────────────────┐
│ Internet │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Nginx (反向代理) │
│ - SSL 终止 │
│ - Referer 校验 │
│ - 安全响应头 │
└─────────────────┬───────────────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Admin │
│ (Vue SPA) │ │ (Spring Boot) │ │ (Vue SPA) │
└─────────────────┘ └────────┬────────┘ └─────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ MySQL │ │ Redis │ │ uploads │
└─────────────┘ └─────────────┘ └─────────────┘
核心安全需求
业务安全需求
- API 访问控制:API 接口只能被官网和管理后台调用,禁止外部直接访问
- 敏感接口保护:管理后台接口必须登录后才能访问
- 防恶意攻击:防止恶意刷接口、暴力破解等攻击行为
- IP 访问限制:禁止通过 IP 地址直接访问服务器
安全威胁分析
| 威胁类型 | 风险描述 | 防护措施 |
|---|---|---|
| API 滥用 | 外部直接调用公开接口获取数据 | Referer 校验 |
| 恶意请求 | 高频请求导致服务不可用 | Rate Limiting |
| 数据泄露 | 敏感接口未授权访问 | JWT 认证 |
| 中间人攻击 | HTTP 明文传输 | HTTPS 强制 |
| 点击劫持 | 页面被嵌入恶意网站 | X-Frame-Options |
解决方案
一、Nginx 安全配置详解
核心配置文件
# nginx.conf - 主配置文件
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 重要:设置 server_name 哈希桶大小
server_names_hash_bucket_size 128;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json
application/javascript application/rss+xml;
include /etc/nginx/conf.d/*.conf;
}
站点安全配置
# enterprise.conf - 站点配置
# ==========================================
# 1. 禁止 IP 直接访问
# ==========================================
server {
listen 80 default_server;
server_name _;
return 444; # 444 表示直接关闭连接,不返回任何响应
}
# ==========================================
# 2. HTTP 强制跳转 HTTPS
# ==========================================
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
# ==========================================
# 3. 主服务 HTTPS 配置
# ==========================================
server {
listen 443 ssl;
server_name example.com www.example.com;
# SSL 证书配置
ssl_certificate /etc/nginx/ssl/example.com.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
# 安全响应头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 请求体大小限制
client_max_body_size 50M;
# ==========================================
# API 接口 - Referer 校验
# ==========================================
location /api/ {
# 关键配置:使用正则表达式匹配域名
valid_referers none blocked server_names ~\.example\.com;
if ($invalid_referer) {
return 403;
}
proxy_pass http://backend:18080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ==========================================
# WebSocket 支持
# ==========================================
location /ws/ {
valid_referers none blocked server_names ~\.example\.com;
if ($invalid_referer) {
return 403;
}
proxy_pass http://backend:18080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
# ==========================================
# 管理后台
# ==========================================
location /admin/ {
alias /usr/share/nginx/admin/;
try_files $uri $uri/ /admin/index.html;
}
# ==========================================
# 官网前端
# ==========================================
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
关键配置说明
⚠️ valid_referers 配置陷阱
错误写法:
valid_referers none blocked server_names example.com www.example.com;
问题:直接写域名会导致与 server_name 参数冲突,报错:
conflicting parameter "example.com" in /etc/nginx/nginx.conf
正确写法:
valid_referers none blocked server_names ~\.example\.com;
原因分析:
server_names已经包含了当前 server 块中定义的域名- 直接再写域名会被 Nginx 解析为重复定义
- 使用正则表达式
~\.example\.com可以匹配所有子域名
valid_referers 参数说明
| 参数 | 说明 |
|---|---|
none | 允许没有 Referer 头的请求(如直接输入 URL) |
blocked | 允许 Referer 被防火墙修改的请求 |
server_names | 允许 Referer 为当前 server_name |
~\.example\.com | 正则匹配,允许所有 example.com 子域名 |
二、后端安全防护实现
Rate Limiting(限流)
使用 Redis 实现分布式限流:
@Slf4j
@Component
@Order(2)
public class RateLimitFilter implements Filter {
@Autowired
private StringRedisTemplate redisTemplate;
@Value("${rate.limit.enabled:true}")
private boolean rateLimitEnabled;
@Value("${rate.limit.requests-per-minute:60}")
private int requestsPerMinute;
@Value("${rate.limit.api-requests-per-minute:30}")
private int apiRequestsPerMinute;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
if (!rateLimitEnabled) {
chain.doFilter(request, response);
return;
}
String clientIp = getClientIp(httpRequest);
String path = httpRequest.getRequestURI();
// 根据路径选择限流策略
int limit = path.startsWith("/api/admin") ? apiRequestsPerMinute : requestsPerMinute;
String key = "rate_limit:" + clientIp + ":" + path.startsWith("/api/admin") ? "admin" : "normal";
try {
Long currentCount = redisTemplate.opsForValue().increment(key);
if (currentCount == 1) {
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
if (currentCount > limit) {
log.warn("请求频率超限: ip={}, path={}, count={}", clientIp, path, currentCount);
httpResponse.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
httpResponse.setContentType("application/json;charset=UTF-8");
httpResponse.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
return;
}
} catch (Exception e) {
log.error("限流检查异常", e);
}
chain.doFilter(request, response);
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
Security Audit(安全审计)
监控可疑行为并自动封禁:
@Slf4j
@Component
@Order(3)
public class SecurityAuditFilter implements Filter {
@Autowired
private StringRedisTemplate redisTemplate;
@Value("${security.audit.enabled:true}")
private boolean auditEnabled;
@Value("${security.audit.suspicious-threshold:10}")
private int suspiciousThreshold;
private static final Set<String> SENSITIVE_PATHS = Set.of(
"/api/auth/login",
"/api/admin"
);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
if (!auditEnabled) {
chain.doFilter(request, response);
return;
}
String clientIp = getClientIp(httpRequest);
String path = httpRequest.getRequestURI();
// 检查是否被封禁
String blockKey = "security:block:" + clientIp;
if (Boolean.TRUE.equals(redisTemplate.hasKey(blockKey))) {
log.warn("IP已被封禁: ip={}", clientIp);
httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
httpResponse.setContentType("application/json;charset=UTF-8");
httpResponse.getWriter().write("{\"code\":403,\"message\":\"访问已被禁止\"}");
return;
}
chain.doFilter(request, response);
// 记录失败请求
int status = httpResponse.getStatus();
if (status >= 400 && isSensitivePath(path)) {
String failKey = "security:fail:" + clientIp;
Long failCount = redisTemplate.opsForValue().increment(failKey);
redisTemplate.expire(failKey, 1, TimeUnit.HOURS);
if (failCount >= suspiciousThreshold) {
log.warn("检测到可疑行为,封禁IP: ip={}, failCount={}", clientIp, failCount);
redisTemplate.opsForValue().set(blockKey, "1", 24, TimeUnit.HOURS);
}
}
}
private boolean isSensitivePath(String path) {
return SENSITIVE_PATHS.stream().anyMatch(path::startsWith);
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
配置文件
# application-prod.yml
rate:
limit:
enabled: true
requests-per-minute: 60 # 普通接口限流
api-requests-per-minute: 30 # 管理接口限流
security:
audit:
enabled: true
suspicious-threshold: 10 # 失败次数阈值
三、Docker Compose 部署配置
# docker-compose.yml
services:
nginx:
image: nginx:alpine
container_name: enterprise-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/enterprise.conf:/etc/nginx/conf.d/default.conf:ro
- ./frontend/dist:/usr/share/nginx/html:ro
- ./admin/dist:/usr/share/nginx/admin:ro
- ./uploads:/var/www/uploads:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- backend
networks:
- enterprise-network
backend:
image: eclipse-temurin:17-jre-alpine
container_name: enterprise-backend
restart: unless-stopped
working_dir: /app
environment:
- SPRING_PROFILES_ACTIVE=prod
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/enterprise_website
- SPRING_DATASOURCE_USERNAME=enterprise
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
- SPRING_DATA_REDIS_HOST=redis
- JWT_SECRET=${JWT_SECRET}
volumes:
- ./backend/enterprise-backend.jar:/app/app.jar:ro
- ./uploads:/app/uploads
command: sh -c "java -Xms256m -Xmx512m -jar app.jar"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- enterprise-network
mysql:
image: mysql:8.0
container_name: enterprise-mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=enterprise_website
- MYSQL_USER=enterprise
- MYSQL_PASSWORD=${DB_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
networks:
- enterprise-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: enterprise-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- enterprise-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
enterprise-network:
driver: bridge
volumes:
mysql-data:
redis-data:
实践要点
安全验证测试
# 1. 测试外部 Referer(应返回 403)
curl -s -o /dev/null -w '%{http_code}' \
-H 'Referer: https://evil.com/' \
https://example.com/api/website/config --insecure
# 预期输出: 403
# 2. 测试官网 Referer(应返回 200)
curl -s -o /dev/null -w '%{http_code}' \
-H 'Referer: https://example.com/' \
https://example.com/api/website/config --insecure
# 预期输出: 200
# 3. 测试管理平台 Referer(应返回 200)
curl -s -o /dev/null -w '%{http_code}' \
-H 'Referer: https://example.com/admin/' \
https://example.com/api/website/config --insecure
# 预期输出: 200
# 4. 测试 IP 直接访问(应无响应)
curl -s -o /dev/null -w '%{http_code}' \
http://192.168.1.100/ --connect-timeout 5
# 预期输出: 000 (连接被关闭)
# 5. 测试 HTTP 重定向
curl -s -o /dev/null -w '%{http_code}' \
http://example.com/ -L --insecure
# 预期输出: 200 (自动跳转到 HTTPS)
测试结果
| 测试项 | 预期结果 | 实际结果 | 状态 |
|---|---|---|---|
| 外部 Referer 访问 API | 403 | 403 | ✅ |
| 官网 Referer 访问 API | 200 | 200 | ✅ |
| 管理平台 Referer 访问 API | 200 | 200 | ✅ |
| IP 直接访问 | 444/无响应 | 000 | ✅ |
| 管理后台页面 | 200 | 200 | ✅ |
| 官网首页 | 200 | 200 | ✅ |
常见问题
Q1: Nginx 报错 "conflicting parameter"
问题:
nginx: [emerg] conflicting parameter "example.com" in /etc/nginx/nginx.conf
原因:valid_referers 中直接写域名与 server_name 冲突
解决方案:使用正则表达式
valid_referers none blocked server_names ~\.example\.com;
Q2: SPA 应用如何实现 API 签名验证?
分析:SPA 应用代码运行在用户浏览器中,无法安全存储签名密钥
解决方案:
- 放弃客户端签名验证
- 使用 Referer 校验替代
- 后端实现 Rate Limiting
- 敏感接口强制 JWT 认证
Q3: 如何处理 Referer 为空的情况?
场景:
- 用户直接在地址栏输入 URL
- 书签访问
- 某些浏览器隐私模式
配置:
valid_referers none blocked server_names ~\.example\.com;
none:允许无 Referer 的请求blocked:允许 Referer 被代理/防火墙修改的请求
Q4: 如何防止 Referer 伪造?
说明:Referer 可以被伪造,但有一定门槛
多层防护策略:
- Nginx Referer 校验:阻止简单的直接调用
- Rate Limiting:限制请求频率
- Security Audit:监控异常行为
- JWT 认证:敏感接口必须登录
- CORS 配置:限制跨域请求来源
安全最佳实践清单
网络层安全
- 强制 HTTPS,禁用 HTTP
- 禁止 IP 直接访问
- 配置 HSTS 响应头
- 使用 TLS 1.2+ 协议
应用层安全
- API Referer 校验
- Rate Limiting 限流
- 敏感接口 JWT 认证
- SQL 注入防护(使用参数化查询)
- XSS 防护(输出编码)
容器安全
- 使用最小化镜像(alpine)
- 只读挂载配置文件
- 环境变量管理敏感信息
- 容器网络隔离
运维安全
- 定期更新依赖版本
- 日志监控与告警
- 定期备份数据
- 制定应急响应计划
总结
本文详细介绍了企业级 Web 应用的安全部署实践,核心要点:
- Nginx Referer 校验是保护 API 不被外部直接调用的有效手段,但需注意配置语法
- 多层防护策略比单一措施更可靠,应结合网络层、应用层、容器层的安全措施
- 安全与可用性的平衡:Referer 校验允许空值,避免影响正常用户体验
- 持续监控:通过 Security Audit 实时监控异常行为,自动封禁恶意 IP
安全是一个持续的过程,需要定期审查和更新防护策略,以应对不断演变的安全威胁。
参考资料
本文由「无边界科技」技术团队分享,专注软件开发与技术解决方案,转载请注明出处。官网:[wubianj.com](wubianj.com