API 漏洞的工程解剖:深入解读 OWASP API Security Top 10
大多数安全类文章读起来像威胁报告,习惯站在外部视角描述漏洞:攻击者做了什么、造成什么影响、全球有多少系统受波及。这些背景信息有价值,但对工程师改进系统设计帮助不大。
这篇文章的视角不同。它从内部剖析 OWASP API Security Top 10 中的每一类漏洞:是哪个工程决策让漏洞得以存在、违反了哪条架构原则、应该遵循怎样的工程标准才能杜绝它上线。
我在受监管的金融环境中做过多年大规模生产应用开发。这份清单里的漏洞对我来说并不抽象——大多数我都在真实系统中见过。有的是我在上线前拦下的,有的是我入职时就已经在线上运行的。每一个本都可以避免……而且靠的不是安全工具,而是工程纪律。
这正是本文的切入角度。
目录
前置知识
阅读本文前,你应该熟悉以下内容:
用任意语言或框架构建 API
对认证有基本了解:知道 token 和 session 是什么
大致了解数据库查询是怎么回事
常见的软件架构概念,如分层、服务、网关
你不需要有安全背景。本文从工程视角、而非安全分析师视角来讲解每一个漏洞。
什么是 OWASP API Security Top 10?
OWASP 全称 Open Web Application Security Project(开放网络应用安全项目),是一个非营利基金会,为工程师和组织提供免费的安全指南。OWASP API Security Top 10 是一份定期更新的清单,汇总了全球生产系统中最严重的 API 漏洞。
这不是一份理论清单,而是基于真实的安全事件、渗透测试结果和各行业生产系统的漏洞报告整理而成。清单上的每一项都曾导致真实组织发生数据泄露、财务损失和监管处罚。
对构建 API 的工程师来说,理解这份清单不是可选项,而是必备的基础知识。
1. 对象级授权失效(BOLA)
是什么
用户已通过认证,持有有效 token,却只需修改请求中的某个标识符,就能访问不属于他的数据。
GET /api/accounts/12345/transactions
用户 A 已通过认证,拥有账号 12345。他把账号改成了:
GET /api/accounts/99999/transactions
如果 API 返回了用户 B 的交易记录,就存在 BOLA 漏洞。用户确实通过了认证,但 API 没有验证这个认证用户是否拥有所请求的资源。
这是全球最常见的 API 漏洞,常年位居 OWASP 榜首——因为它太容易引入,也太容易在代码审查中被漏掉。
工程上的失败
BOLA 产生的根源在于把授权当成一个二元问题:这个用户认证通过了吗?是或否。API 只检查用户有没有合法 token,检查完就到此为止。它从来不问第二个问题:这个已认证的用户,是不是真的拥有他请求的那个资源?
这种认证是与资源无关的。它能证明你是谁,却永远无法证明你要的东西是否属于你。
工程上的修复方案
授权必须放在 service 层做,而不能只依赖 API 网关。API 网关只能验证 token 是否有效,只有 service 层才知道已认证的用户是否拥有被请求的资源。
Dart(service 层强制校验):
class TransactionService {
final TransactionRepository _repository;
final AuthContext _authContext;
TransactionService(this._repository, this._authContext);
Future<Result<List<Transaction>, AppException>> getTransactions(
String accountId,
) async {
final currentUserId = _authContext.currentUserId;
// 先查询账户
final account = await _repository.findAccountById(accountId);
if (account == null) {
return Result.failure(AppException.notFound('Account not found'));
}
// 返回数据前先校验所有权
if (account.ownerId != currentUserId) {
return Result.failure(
AppException.forbidden('Access denied to this account'),
);
}
final transactions = await _repository.findByAccountId(accountId);
return Result.success(transactions);
}
}
C#:
public async Task<Result<IEnumerable<Transaction>>> GetTransactions(
string accountId,
ClaimsPrincipal currentUser)
{
var userId = currentUser.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var account = await _repository.FindAccountByIdAsync(accountId);
if (account == null)
return Result.Failure<IEnumerable<Transaction>>("Account not found");
// 所有权校验 —— 绝不能省略这一步
if (account.OwnerId != userId)
return Result.Failure<IEnumerable<Transaction>>("Access denied");
var transactions = await _repository.FindByAccountIdAsync(accountId);
return Result.Success(transactions);
}
每次访问具体资源的请求都必须做所有权校验,不能只在首次加载或写操作时做,而是每一个请求都要做。
2. 身份认证失效
这是什么
弱令牌、令牌不过期、认证接口没有限流,或者登出后会话依然不终止。总之,凡是识别请求者身份的机制存在薄弱环节,都算这一类。
工程上的失败
认证一旦能跑通,就被当成已解决的问题。工程师实现登录、拿到令牌,然后就转去做别的了。各种失败场景——令牌被盗怎么办、攻击者拿密码字典轰击登录接口怎么办、用户登出后会话如何处理——从来没有被纳入设计。
认证机制必须针对对抗性场景来设计,而不是只考虑正常使用路径。
工程上的修复
令牌过期是强制要求。Access token 应该短命:15 分钟到 1 小时,会话连续性交给 refresh token 处理。短期令牌能压缩令牌被盗后的危害窗口。
认证接口限流是强制要求。登录接口不限流,等于公开邀请暴力破解。攻击者手里如果有 1000 万组邮箱和密码的组合,会把每一个都系统性地试一遍。
登出必须真正终止会话。如果使用有状态会话,登出时必须在服务端使其失效;如果使用 JWT,就要维护令牌黑名单,或者采用超短有效期加 refresh token 轮换的方案。
Dart:
class AuthService {
final TokenRepository _tokenRepository;
final RateLimiter _rateLimiter;
AuthService(this._tokenRepository, this._rateLimiter);
Future<Result<AuthTokens, AppException>> login(
String email,
String password,
String ipAddress,
) async {
// 按 IP 限流,防止暴力破解
final isAllowed = await _rateLimiter.checkLimit(
key: 'login:$ipAddress',
maxAttempts: 5,
windowSeconds: 300,
);
if (!isAllowed) {
return Result.failure(
AppException.rateLimited('登录尝试次数过多,请稍后再试。'),
);
}
final user = await _validateCredentials(email, password);
if (user == null) {
return Result.failure(
AppException.unauthorized('凭据无效'),
);
}
// 短期有效的 access token
final accessToken = _generateAccessToken(user, expiryMinutes: 15);
// 有效期较长的 refresh token,存储在服务端
final refreshToken = _generateRefreshToken(user);
await _tokenRepository.storeRefreshToken(user.id, refreshToken);
return Result.success(AuthTokens(
accessToken: accessToken,
refreshToken: refreshToken,
));
}
Future<void> logout(String userId, String refreshToken) async {
// 登出时在服务端使 refresh token 失效
await _tokenRepository.revokeRefreshToken(userId, refreshToken);
}
}
C#:
public async Task<Result<AuthTokens>> Login(
LoginRequest request,
string ipAddress)
{
var isAllowed = await _rateLimiter.CheckLimit(
key: $"login:{ipAddress}",
maxAttempts: 5,
windowSeconds: 300);
if (!isAllowed)
return Result.Failure<AuthTokens>("登录尝试次数过多。");
var user = await _userService.ValidateCredentials(
request.Email,
request.Password);
if (user == null)
return Result.Failure<AuthTokens>("凭据无效");
var accessToken = _tokenService.GenerateAccessToken(user, expiryMinutes: 15);
var refreshToken = _tokenService.GenerateRefreshToken(user);
await _tokenRepository.StoreRefreshToken(user.Id, refreshToken);
return Result.Success(new AuthTokens(accessToken, refreshToken));
}
public async Task Logout(string userId, string refreshToken)
{
await _tokenRepository.RevokeRefreshToken(userId, refreshToken);
}
3. 对象属性级授权失效(BOPLA)
这是什么
API 返回了超出用户所需的数据。本不该离开服务器的内部标志位、管理员权限、敏感字段,全都出现在了响应负载里。更糟的情况是:API 接受了用户本不该设置的数据,让用户得以修改自己无权触碰的字段。
比如用户获取自己的个人信息,响应里却包含 isAdmin: false、internalAccountScore: 742 或 fraudRiskLevel: "low"。这些字段都不应该出现在面向用户的响应中。
再比如用户更新个人信息时,请求体里带了 "role": "admin"。API 照单全收,用户就这样把自己提升成了管理员。
工程上的失败
这是一个结构性问题:系统在构建时没有刻意设计响应 schema。有些开发者图省事,直接把原始数据库实体返回;有些则缺乏领域知识,无法合理组织数据层。结果就是内部数据泄漏到了外部响应中。
这也是对接口隔离原则(Interface Segregation Principle)的违反——响应接口迫使调用方接收它本不该接触的数据。
工程上的修复
每个 API 端点都必须有明确且经过设计的响应 DTO,其中只包含调用方有权获取的字段。领域实体绝不能直接进入响应。
Dart:
//错误:直接返回领域实体——暴露内部字段
Future<Response> getProfile(Request request) async {
final user = await _userRepository.findById(userId);
return Response.ok(jsonEncode(user.toJson())); // 全部暴露了
}
// 正确:使用显式的响应 DTO——只返回调用方应该看到的内容
class UserProfileResponse {
final String id;
final String firstName;
final String lastName;
final String email;
const UserProfileResponse({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
});
Map<String, dynamic> toJson() => {
'id': id,
'first_name': firstName,
'last_name': lastName,
'email': email,
// isAdmin、internalScore、fraudRiskLevel——绝不出现在这里
};
}
Future<Response> getProfile(Request request) async {
final user = await _userRepository.findById(userId);
final response = UserProfileResponse(
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
);
return Response.ok(jsonEncode(response.toJson()));
}
C#:
// 错误:直接返回领域模型
public async Task<IActionResult> GetProfile(string userId)
{
var user = await _repository.FindByIdAsync(userId);
return Ok(user);
}
// 正确:显式的响应 DTO
public record UserProfileResponse(
string Id,
string FirstName,
string LastName,
string Email);
public async Task<IActionResult> GetProfile(string userId)
{
var user = await _repository.FindByIdAsync(userId);
var response = new UserProfileResponse(
user.Id,
user.FirstName,
user.LastName,
user.Email
// IsAdmin、InternalScore、FraudRiskLevel——绝不暴露
);
return Ok(response);
}
对于传入的请求,应使用请求 DTO,只接受用户有权修改的字段。更新操作中绝不要直接绑定领域实体。
4. 不受限制的资源消耗
问题是什么
没有速率限制。调用方可以对关键 API 每分钟发起成千上万次请求,比如爬取数据、暴力破解或拒绝服务攻击。API 对每个请求照单全收,没有任何限流或资源消耗限制。
工程上的失误
限流常被当作可选项,或者“以后再说”的事——“等需要扩容时再做”“等看到滥用时再做”。可等滥用真的出现时,损失早已在发生。一个没有限流的金融 API,可能被暴力破解有效账号、被批量抓取定价数据,或者直接被流量压垮而不可用。
工程上的解决方案
限流必须在 API 网关层实现,在请求到达服务层之前完成。这不是服务的职责。网关才是正确的执行点,因为它可以一次性为所有服务做限流,而不需要每个服务各自重复实现。
不同的端点需要不同的限流策略。认证端点需要严格限制(每个 IP 每 5 分钟 5 次尝试)。公开的读端点用中等限制。敏感资源的写操作则需要严格限制。
Dart(middleware 方式):
class RateLimitMiddleware {
final RateLimiter _limiter;
RateLimitMiddleware(this._limiter);
Handler call(Handler innerHandler) {
return (Request request) async {
final clientIp = request.headers['x-forwarded-for'] ?? 'unknown';
final endpoint = request.url.path;
final limit = _getLimitForEndpoint(endpoint);
final isAllowed = await _limiter.checkLimit(
key: '$clientIp:$endpoint',
maxRequests: limit.maxRequests,
windowSeconds: limit.windowSeconds,
);
if (!isAllowed) {
return Response(
429,
body: jsonEncode({'error': 'Rate limit exceeded'}),
headers: {'Retry-After': '60'},
);
}
return innerHandler(request);
};
}
RateLimit _getLimitForEndpoint(String path) {
if (path.contains('/auth/login')) {
return RateLimit(maxRequests: 5, windowSeconds: 300);
}
if (path.contains('/transactions')) {
return RateLimit(maxRequests: 100, windowSeconds: 60);
}
return RateLimit(maxRequests: 1000, windowSeconds: 60);
}
}
C#:
// 使用 AspNetCoreRateLimit
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("auth", limiterOptions =>
{
limiterOptions.PermitLimit = 5;
limiterOptions.Window = TimeSpan.FromMinutes(5);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
});
options.AddFixedWindowLimiter("standard", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
});
options.RejectionStatusCode = 429;
});
// 按控制器应用
[EnableRateLimiting("auth")]
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request) { }
[EnableRateLimiting("standard")]
[HttpGet("transactions")]
public async Task<IActionResult> GetTransactions() { }
5. 函数级别授权失效(BFLA)
它是什么
普通用户能够访问本应只有管理员或特权角色才能使用的功能或端点。前端把按钮藏起来了,但端点还好好地开在那儿。
攻击者拿普通用户的 token 去调用管理员端点,居然成功了。于是用一个非管理员账号,就拿到了管理员的功能。
靠隐藏来实现的安全不是真正的安全。在 UI 层面藏起管理资源,不等于做了防护。
工程上的失败
RBAC(基于角色的访问控制)要么压根没实现,要么实现得有问题——函数级别缺少授权检查。开发者想当然地认为:用户在界面上看不到管理员按钮,就调不了管理员 API。这个假设永远是错的。任何开发工具都能直接调用任意 API 端点,完全绕过 UI。
工程上的修复
每个函数在执行前都必须校验调用者的角色。这个校验必须放在服务层,而不是 UI 层,也不能只靠 API 网关。网关只能验证 token 是否有效,只有服务层才知道每个具体操作需要什么角色。
Dart:
class UserManagementService {
final AuthContext _authContext;
final UserRepository _repository;
UserManagementService(this._authContext, this._repository);
Future<Result<void, AppException>> deleteUser(String targetUserId) async {
final currentUser = _authContext.currentUser;
// 角色检查——必须在 service 层完成
if (!currentUser.hasRole(UserRole.admin)) {
return Result.failure(
AppException.forbidden('Admin role required for this operation'),
);
}
await _repository.deleteUser(targetUserId);
return Result.success(null);
}
Future<Result<List<User>, AppException>> getAllUsers() async {
final currentUser = _authContext.currentUser;
if (!currentUser.hasRole(UserRole.admin)) {
return Result.failure(
AppException.forbidden('Admin role required'),
);
}
final users = await _repository.findAll();
return Result.success(users);
}
}
C#:
[ApiController]
[Route("api/admin/users")]
[Authorize]
public class UserManagementController : ControllerBase
{
private readonly IUserManagementService _service;
[HttpDelete("{userId}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteUser(string userId)
{
await _service.DeleteUser(userId);
return NoContent();
}
[HttpGet]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetAllUsers()
{
var users = await _service.GetAllUsers();
return Ok(users);
}
}
无论是 C# 中属性级别的角色检查,还是 Dart 中 service 层的显式角色校验,本质上都是一回事:检查发生在代码里,而不是 UI 里,也不是靠 API 调用方的行为来保证。
6. 敏感业务流程无限制访问
它是什么
本应有严格防护的核心业务逻辑,却没有做任何限制。用户可以绕过业务规则,触发在当前场景下根本不该被允许的流程。
举个例子:销售代表给一个不在服务覆盖区域内的客户创建订单,而创建订单的接口从未校验该区域是否在覆盖范围内。业务规则被违反了。从财务角度看,这会带来损失;从运营角度看,这会造成需要好几个月才能理清的烂摊子。
工程上的失败
业务逻辑没有在领域层被识别和强制执行。领域驱动设计(Domain-Driven Design)的存在正是为了解决这个问题:业务逻辑决定着应用的成败。如果领域层设计不当,业务规则没有被显式地编码进去,用户就能绕过这些规则。
防护机制之所以缺失,是因为工程师根本不知道那里本该有防护。这既是技术问题,更是领域知识的问题。
工程层面的修复
业务规则应该放在领域层。这正是 Value Objects 和领域实体要强制保证的。没有确认承保范围,就不能创建销售记录——这条规则要编码进领域模型里,而不是 API 接口或 UI。
Dart:
class Sale {
final String agentId;
final String customerId;
final CoverageArea coverageArea;
final SaleStatus status;
Sale._({
required this.agentId,
required this.customerId,
required this.coverageArea,
required this.status,
});
// 在领域对象创建时强制执行业务规则——没有承保范围,就没有销售
static Result<Sale, DomainException> create({
required String agentId,
required String customerId,
required CoverageArea coverageArea,
}) {
if (!coverageArea.hasActiveCoverage) {
return Result.failure(
DomainException('Cannot create sale: customer area has no active coverage'),
);
}
return Result.success(Sale._(
agentId: agentId,
customerId: customerId,
coverageArea: coverageArea,
status: SaleStatus.pending,
));
}
}
C#:
public class Sale
{
private Sale(string agentId, string customerId, CoverageArea area)
{
AgentId = agentId;
CustomerId = customerId;
CoverageArea = area;
Status = SaleStatus.Pending;
}
public string AgentId { get; }
public string CustomerId { get; }
public CoverageArea CoverageArea { get; }
public SaleStatus Status { get; private set; }
// 工厂方法强制执行“无承保范围则无法成交”的业务规则
public static Result<Sale> Create(
string agentId,
string customerId,
CoverageArea coverageArea)
{
if (!coverageArea.HasActiveCoverage)
return Result.Failure<Sale>(
"Cannot create sale: no active coverage in this area");
return Result.Success(new Sale(agentId, customerId, coverageArea));
}
}
创建销售的接口调用领域工厂,由领域工厂强制执行业务规则。这条规则无法通过 API 绕过,因为 API 想创建 Sale 就必须经过工厂。
7. 安全配置错误
这是什么
把开发者个人的调试习惯直接带上了生产环境:详尽的错误信息暴露了堆栈跟踪、调试端点没有关闭、CORS 过于宽松允许任意来源、日志中包含敏感数据,以及开发者的本地配置直接跑在生产环境里。
工程上的失败
这类问题的根源通常在于开发、预发布和生产环境的配置没有隔离,流水线中也没有能在上线前拦住错误配置的门禁。每个开发者各自管理自己的配置,结果就是任何人的习惯和调试偏好都可能被带到生产环境。
工程上的修复
为每个环境设置独立的配置流程。生产流水线必须包含静态分析、配置校验和 lint 检查,在允许合并之前就拦截调试端点、宽松的 CORS、冗长的错误日志和暴露的密钥。
Dart(基于环境的错误处理):
class ErrorHandler {
final Environment _environment;
ErrorHandler(this._environment);
Response handleException(Object error, StackTrace stackTrace) {
logger.error('Unhandled exception', error: error, stackTrace: stackTrace);
if (_environment.isProduction) {
return Response.internalServerError(
body: jsonEncode({'error': 'An internal error occurred'}),
);
}
return Response.internalServerError(
body: jsonEncode({
'error': error.toString(),
'stackTrace': stackTrace.toString(),
}),
);
}
}
C#:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var error = context.Features.Get<IExceptionHandlerFeature>();
if (error != null)
{
// log internally with full details
logger.LogError(error.Error, "Unhandled exception");
}
// return generic message to caller
await context.Response.WriteAsync(
JsonSerializer.Serialize(new { error = "An internal error occurred" })
);
});
});
// CORS - explicit allowed origins, never wildcard in production
builder.Services.AddCors(options =>
{
options.AddPolicy("ProductionPolicy", policy =>
{
policy.WithOrigins(
"https://app.yourproduct.com",
"https://admin.yourproduct.com"
)
.AllowedMethods("GET", "POST", "PUT", "DELETE")
.AllowedHeaders("Authorization", "Content-Type");
});
});
生产环境的 CORS 必须配置明确的允许来源。在生产环境使用通配符 CORS 是直接的安全失误——它允许互联网上的任意网页利用访问者的凭证向你的 API 发起已认证的请求。
8. 资产管理不当(Improper Inventory Management)
这是什么
指已废弃的 API、下线的端点、过时的 API 版本仍在服务器上运行。没人知道它们的存在,也没人负责维护。但攻击者能找到并利用它们,因为旧端点的安全防护往往不如现行版本。
工程层面的失败
API 生命周期管理往往没人正式负责。接口不断被创建,功能不断在变化,而旧版本的接口不是被下线,而是被遗忘。久而久之,API 的暴露面越来越大,一批“死接口”仍在响应请求,而且往往没有像新接口那样加上相应的安全控制。
工程上的解决之道
必须有人对 API 清单负责,这是正式的工程职责,而不是可选的实践。每个 API 接口都应该有文档、有版本管理,并明确定义生命周期状态:active(活跃)、deprecated(已弃用)或 decommissioned(已下线)。
已弃用的接口应在响应头中标注弃用日期;已下线的接口则必须返回 410 Gone,而不是继续处理请求。
Dart:
// deprecated endpoint wrapper
Handler deprecatedEndpoint({
required Handler handler,
required DateTime removalDate,
required String replacementEndpoint,
}) {
return (Request request) async {
final response = await handler(request);
// add deprecation headers so clients know to migrate
return response.change(headers: {
'Deprecation': 'true',
'Sunset': HttpDate.format(removalDate),
'Link': '<$replacementEndpoint>; rel="successor-version"',
'Warning': '299 - "This endpoint is deprecated and will be removed on ${removalDate.toIso8601String()}"',
});
};
}
// decommissioned endpoint
Future<Response> decommissionedEndpoint(Request request) async {
return Response(
410,
body: jsonEncode({
'error': 'This endpoint has been permanently removed',
'replacement': '/api/v2/accounts',
}),
);
}
C#:
// mark endpoints as deprecated using ApiVersion attributes
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/v{version:apiVersion}/accounts")]
public class AccountsV1Controller : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetAccount(string id)
{
Response.Headers.Add("Deprecation", "true");
Response.Headers.Add("Sunset", "Sat, 01 Jan 2027 00:00:00 GMT");
Response.Headers.Add("Link", "</api/v2/accounts/{id}>; rel=\"successor-version\"");
// still process the request during deprecation period
return Ok(_service.GetAccount(id));
}
}
// gone - permanent removal
[ApiController]
[Route("api/v1/legacy/accounts")]
public class LegacyAccountsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
return StatusCode(410, new { error = "This endpoint has been permanently removed", replacement = "/api/v2/accounts" });
}
}
9. API 不安全调用
这是什么
你的应用集成了第三方服务,却不加任何校验和防护就信任对方的数据。外部 API 返回什么,应用就直接处理什么。
第三方支付回调返回一笔交易的状态,应用不验签、不校验数据结构就直接采信。攻击者伪造一个回调发到你的回调地址,应用就会当作合法请求来处理。
工程上的失误
外部服务被默认当成可信来源,没有定义外部服务返回的合法数据应满足什么契约,外部服务响应和应用的业务逻辑之间也没有校验层。
信任是假设出来的,而不是验证出来的。
工程上的修复
每一次对接外部服务都必须定义契约:明确合法数据的形态、必填字段、有效取值范围和格式。外部服务返回的每个响应,都要先对照契约校验,再做任何处理。
Dart:
class PaymentCallbackService {
final String _webhookSecret;
PaymentCallbackService(this._webhookSecret);
Future<Result<PaymentStatus, AppException>> processCallback(
Map<String, dynamic> payload,
String signature,
String rawBody,
) async {
// 第一步:先验证签名,再做任何处理
final isValid = _verifySignature(rawBody, signature, _webhookSecret);
if (!isValid) {
logger.warning('Invalid webhook signature received');
return Result.failure(
AppException.unauthorized('Invalid webhook signature'),
);
}
// 第二步:按约定的契约校验 payload 结构
final validationResult = _validateCallbackPayload(payload);
if (validationResult.isFailure) {
logger.warning('Invalid callback payload: ${validationResult.error}');
return Result.failure(validationResult.error!);
}
// 第三步:到这里才信任并处理数据
final status = PaymentStatus.fromString(payload['status'] as String);
return Result.success(status);
}
bool _verifySignature(String body, String signature, String secret) {
final hmac = Hmac(sha256, utf8.encode(secret));
final digest = hmac.convert(utf8.encode(body));
final expectedSignature = 'sha256=${base64.encode(digest.bytes)}';
return expectedSignature == signature;
}
Result<void, AppException> _validateCallbackPayload(
Map<String, dynamic> payload,
) {
if (!payload.containsKey('transaction_id')) {
return Result.failure(
AppException.validation('Missing required field: transaction_id'),
);
}
if (!payload.containsKey('status')) {
return Result.failure(
AppException.validation('Missing required field: status'),
);
}
final validStatuses = {'success', 'failed', 'pending'};
if (!validStatuses.contains(payload['status'])) {
return Result.failure(
AppException.validation('Invalid status value: ${payload['status']}'),
);
}
return Result.success(null);
}
}
C#:
public class PaymentCallbackService
{
private readonly string _webhookSecret;
private readonly ILogger<PaymentCallbackService> _logger;
public async Task<Result<PaymentStatus>> ProcessCallback(
string rawBody,
string signature,
PaymentCallbackDto payload)
{
// 先验证签名
if (!VerifySignature(rawBody, signature))
{
_logger.LogWarning("Invalid webhook signature received");
return Result.Failure<PaymentStatus>("Invalid webhook signature");
}
// 校验载荷
if (string.IsNullOrEmpty(payload.TransactionId))
return Result.Failure<PaymentStatus>("Missing transaction_id");
var validStatuses = new[] { "success", "failed", "pending" };
if (!validStatuses.Contains(payload.Status))
return Result.Failure<PaymentStatus>($"Invalid status: {payload.Status}");
return Result.Success(Enum.Parse<PaymentStatus>(payload.Status, true));
}
private bool VerifySignature(string body, string signature)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
var expectedSignature = $"sha256={Convert.ToBase64String(hash)}";
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSignature),
Encoding.UTF8.GetBytes(signature)
);
}
}
在集成开始之前,与外部服务建立经过验证的契约应当成为工程标准。永远不要轻信,永远要验证。
10. 服务端请求伪造(SSRF)
它是什么
API 接受一个 URL 作为输入,并在服务端向该 URL 发起请求。攻击者可以提供一个指向内部基础设施的 URL:比如 http://169.254.169.254/latest/meta-data/(AWS 元数据服务)、http://internal-database:5432,或者 http://admin-panel.internal。服务器会从网络内部发起请求,从而绕过外部防火墙。
大多数支付系统都会在请求中接收回调 URL 或重定向 URL。如果 API 不做校验就直接向这些 URL 转发请求,它就会“乐于帮忙”地替攻击者去访问内部基础设施。
工程层面的失败
这是一种必须在架构层而非代码层解决的设计缺陷。任何接受 URL 作为输入并发起服务端请求的 API,都是潜在的 SSRF 攻击目标。接受任意 URL 的设计决策,必须配套严格校验这些 URL 的设计决策。
工程层面的修复
任何接受 URL 的 API,都必须先对照允许的域名列表进行校验,然后才能发起请求。这是一个设计决策:域名白名单应作为 API 规范的一部分,而不是事后补丁。
Dart:
class WebhookService {
// 只有这些域名允许接收回调
static const _allowedDomains = {
'api.yourpartner.com',
'hooks.yourintegration.com',
'callbacks.trustedservice.io',
};
Future<Result<void, AppException>> registerCallbackUrl(String url) async {
// 先校验再存储或使用
final validationResult = _validateCallbackUrl(url);
if (validationResult.isFailure) {
return Result.failure(validationResult.error!);
}
await _webhookRepository.save(url);
return Result.success(null);
}
Result<void, AppException> _validateCallbackUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null) {
return Result.failure(AppException.validation('Invalid URL format'));
}
// 生产环境必须使用 HTTPS
if (uri.scheme != 'https') {
return Result.failure(
AppException.validation('Callback URL must use HTTPS'),
);
}
// 必须在白名单内
if (!_allowedDomains.contains(uri.host)) {
return Result.failure(
AppException.validation(
'Callback URL domain is not in the approved list',
),
);
}
// 显式拦截内网 IP 段
if (_isInternalAddress(uri.host)) {
return Result.failure(
AppException.validation('Callback URL cannot point to internal addresses'),
);
}
return Result.success(null);
}
bool _isInternalAddress(String host) {
final privateRanges = [
'127.', '10.', '172.16.', '172.17.', '172.18.',
'192.168.', '169.254.', 'localhost', '0.0.0.0',
];
return privateRanges.any((range) => host.startsWith(range));
}
}
C#:
public class WebhookService
{
private static readonly HashSet<string> AllowedDomains = new()
{
"api.yourpartner.com",
"hooks.yourintegration.com",
"callbacks.trustedservice.io"
};
public async Task<Result<bool>> RegisterCallbackUrl(string url)
{
var validationResult = ValidateCallbackUrl(url);
if (!validationResult.IsSuccess)
return Result.Failure<bool>(validationResult.Error);
await _repository.SaveCallbackUrl(url);
return Result.Success(true);
}
private Result<bool> ValidateCallbackUrl(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return Result.Failure<bool>("Invalid URL format");
if (uri.Scheme != "https")
return Result.Failure<bool>("Callback URL must use HTTPS");
if (!AllowedDomains.Contains(uri.Host))
return Result.Failure<bool>("Domain not in approved list");
if (IsInternalAddress(uri.Host))
return Result.Failure<bool>("Internal addresses are not permitted");
return Result.Success(true);
}
private bool IsInternalAddress(string host)
{
var internalPrefixes = new[]
{
"127.", "10.", "172.16.", "192.168.",
"169.254.", "localhost", "0.0.0.0"
};
return internalPrefixes.Any(p => host.StartsWith(p));
}
}
OWASP 清单之外的工程漏洞
OWASP 清单覆盖的是最严重、最常见的 API 漏洞,但实际的工程经验中还存在一些其他模式,同样可能造成严重的安全缺口。
被多个客户端调用却未配置域名白名单的 API
有些 API 会被多个客户端调用,但并不限制哪些域名有权调用它。这就使得用户可以随意分享 API 密钥,从未经授权的来源访问资源。
在大型组织中,这是一个经常被忽视的重大安全隐患——因为从技术上讲,API 对每个客户端都“工作正常”。
解决办法:凡是接受 API 密钥的 API,都必须同时校验调用方的域名。API 密钥和允许的域名必须显式绑定。
在代码和日志中泄露密钥
开发者会把密钥、公钥和凭证留在代码里、仓库里或自己的机器上。仅仅把文件加进 .gitignore 是不够的。这些敏感数据必须在运行时从可信的密钥管理服务获取,绝不能写死在源码里。
企业必须使用 Vault、Azure App Configuration、AWS Secrets Manager 或类似的系统。这应该是一条工程规范,而不是开发者的个人偏好。密钥的访问模式也要标准化,免得每个项目都得自己摸索一遍。
微服务之间不经过网关直接通信
如果你在做微服务架构,服务之间的每一次通信都必须当作敏感流量对待。必须有统一的 API 网关负责所有服务的认证。服务之间不能默认互信,每次服务间调用都要经过认证。
微服务之间的通信,异步流程应该用 Kafka 这类事件中间件,同步流程则用 mTLS。任何微服务都不应绕过网关直接暴露在公网上。
缺乏企业级加密标准
企业必须制定标准化的加密算法。不能让开发者各选各的,不能每周换一个“流行算法”,更不能随便抄一段 Stack Overflow 上的答案。应该由资深工程团队拍板:企业统一用哪个算法、哪种模式、多长的密钥。
临时随意选择加密方案带来的漏洞很严重:padding oracle 攻击、弱密钥长度、IV 复用,还有加密失败时堆栈信息向调用方泄露内部实现细节。
工程上的控制手段是:企业统一提供加密用的内部包或云函数,项目直接引用而不是各自实现。开发者调用这个包就行,永远不要在每个项目里从零手写加密。
SQL 注入
SQL 注入已经存在 25 年了,从 1998 年就出现了。但它至今仍在发生,原因就是工程规范没有被强制执行。
问题出在开发者用字符串拼接构造查询:拿到用户输入后直接粘进 SQL 字符串。解决办法只有参数化查询或 prepared statements,别无他法。凡是拼接用户输入的裸 SQL 字符串都不可接受。
Dart:
//错误示范:字符串拼接 —— 存在 SQL 注入漏洞
Future<User?> findUser(String email) async {
final query = "SELECT * FROM users WHERE email = '$email'";
// 攻击者传入:admin@example.com' OR '1'='1
// 查询语句变成:SELECT * FROM users WHERE email = 'admin@example.com' OR '1'='1'
// 返回所有用户
return await database.rawQuery(query);
}
// 参数化查询 —— 可防御注入
Future<User?> findUser(String email) async {
final results = await database.query(
'users',
where: 'email = ?',
whereArgs: [email],
);
return results.isNotEmpty ? User.fromMap(results.first) : null;
}
C#:
// 错误的字符串拼接
public async Task<User?> FindUser(string email)
{
var query = $"SELECT * FROM Users WHERE Email = '{email}'";
return await _context.Users.FromSqlRaw(query).FirstOrDefaultAsync();
}
// 正确做法:使用 EF Core 的参数化查询
public async Task<User?> FindUser(string email)
{
return await _context.Users
.Where(u => u.Email == email)
.FirstOrDefaultAsync();
}
// 确实需要原生 SQL 时的正确参数化写法
public async Task<User?> FindUserRaw(string email)
{
return await _context.Users
.FromSqlRaw("SELECT * FROM Users WHERE Email = {0}", email)
.FirstOrDefaultAsync();
}
这是一类重大漏洞,根源在于核心工程决策,本应受到严格的治理与合规原则约束。这些原则有助于在项目和组织层面确立开发规范。
还可以在 CI/CD 层面强制执行。如果 CI/CD 流水线中的静态分析没有检测到原生 SQL 字符串拼接,团队就应该补上这条规则。这类漏洞绝不该流到生产环境。
结语
这份榜单上的每个漏洞都有一个共同点:本可避免。靠的不是事后加装的安全工具,而是设计和开发阶段贯彻的工程纪律。
BOLA:在服务层实现感知 ID 的授权即可防范。
身份认证失效:靠合理的 token 设计和速率限制防范。
BOPLA:靠显式定义的响应 DTO 防范。
资源无限消耗:靠网关层的速率限制防范。
BFLA:靠代码中的角色校验防范,而不是依赖 UI。
通过领域驱动设计和完善的业务规则约束,防止敏感业务流程泄露。
通过带自动化门禁的按环境隔离流水线,防止安全配置错误。
通过正式的 API 生命周期归属管理,防止清单管理不当。
通过契约优先的外部集成,防止不安全的 API 消费。
把 URL 白名单作为设计决策,防止 SSRF。
规律是一致的。安全不是事后往应用上加的东西,而是从一开始就融入架构的工程设计。本文中的每个决策都是工程决策:这个校验应该放在架构的哪一层?哪个层负责这项验证?领域层强制什么?网关强制什么?流水线捕获什么?
Security by design 不是安全团队的职责,而是工程团队的职责。而这一切的起点,是准确理解这些漏洞究竟是什么、从何而来,以及防范它们的工程标准是什么。
作为软件工程师,这种更深一层的思考能确保我们的代码不倒在安全测试上。
祝安全编码愉快!