freeCodeCamp.org

The Engineering Anatomy of API Vulnerabilities: A Deep Dive into the OWASP API Security Top 10

7.1内容质量

TL;DR · AI 摘要

The Engineering Anatomy of API Vulnerabilities: A Deep Dive into the OWASP API Security Top 10 October 5, 2026 / Securit...

核心要点

  • 主题聚焦:The Engineering Anatomy of API Vulnerabilities:
  • 来源:freeCodeCamp.org,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#前端#后端#云计算
打开原文

API 漏洞的工程剖析:深入解析 OWASP API 安全十大漏洞

2026年10月5日

/

#安全

Oluwaseyi Fatunmole

大多数安全文章都像威胁报告一样。它们从外部视角描述漏洞:攻击者会做什么、影响是什么、全球有多少系统受影响。这种背景信息很有用,但无法帮助工程师构建更安全的系统。

本文有所不同。它从内部视角分析 OWASP API 安全十大漏洞:是什么工程决策导致漏洞存在、违反了哪些架构原则、以及工程标准应如何制定以防止漏洞进入生产环境。

我在受监管的金融环境中构建过大规模生产应用多年。这份列表中的漏洞并非理论上的。我曾在真实系统中看到其中大部分漏洞。有些在进入生产环境前被我捕获,有些在我到任时已经上线。每一个漏洞都是可以预防的……不是通过安全工具,而是通过工程纪律。

这就是本文采用的视角。

目录

  • 先决条件
  • 什么是 OWASP API 安全十大漏洞?
  • 1. 对象级授权缺陷(BOLA)
  • 2. 认证缺陷
  • 3. 对象属性级授权缺陷(BOPLA)
  • 4. 无限制资源消耗
  • 5. 功能级授权缺陷(BFLA)
  • 6. 对敏感业务流程的无限制访问
  • 7. 安全配置缺陷
  • 8. 库存管理不当
  • 9. API 使用不当
  • 10. 服务器端请求伪造(SSRF)
  • 超出 OWASP 列表的工程漏洞
  • 结论

先决条件

在阅读本文之前,你应该熟悉以下内容:

  • 使用任何语言或框架构建 API
  • 对认证的基本理解:令牌和会话的概念
  • 数据库查询的高层视图
  • 一般软件架构概念,如分层、服务和网关

你不需要安全背景。本文从工程视角而非安全分析师视角解释每个漏洞。

什么是 OWASP API 安全十大漏洞?

OWASP 代表开放网络应用安全项目。它是一个非营利基金会,为工程师和组织提供免费的安全指南。OWASP API 安全十大漏洞是全球生产系统中发现的最关键 API 漏洞的定期更新列表。

这不是理论列表。它由真实的安全事件、渗透测试结果以及各行业生产系统的漏洞报告汇总而成。列表中的每一项都曾导致真实的数据泄露、真实的财务损失和真实的监管处罚。

对于构建 API 的工程师来说,理解这份列表不是可选的,而是基础性知识。

1. 对象级授权缺陷(BOLA)

问题描述

用户已通过认证并拥有有效令牌。但他们只需更改请求中的标识符,就能访问不属于自己的数据。

code
GET /api/accounts/12345/transactions

用户 A 已通过认证并拥有账户 12345。他们修改账户编号:

code
GET /api/accounts/99999/transactions

如果 API 返回用户 B 的交易记录,则存在 BOLA。用户已通过认证,但 API 没有验证认证用户是否拥有请求的资源。

这是世界上最常见的API漏洞。它始终位居OWASP榜单前列,因为它很容易被引入,也很容易在代码审查中被忽略。

工程失败

当授权被当作二元问题处理时就会发生BOLA(对象级授权漏洞):用户是否已认证?是或否。API仅检查用户是否持有有效令牌,就此为止。它从未提出第二个问题:这个已认证用户是否拥有他们请求的特定资源?

认证并非与身份无关。它证明了你是谁,但从未证明你请求的资源是否属于你。

工程修复

授权必须在服务层进行,而不仅仅是在API网关。API网关可以验证令牌是否有效,但只有服务层知道已认证用户是否拥有正在请求的资源。

Dart(服务层强制):

code
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#:

code
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. 认证失效

弱令牌、无令牌过期、认证端点无速率限制,或注销后会话永不终止。任何识别请求发起者机制中的弱点。

一旦认证功能正常工作,工程师们通常会认为认证问题已解决。他们实现登录功能,获取令牌后就继续其他工作。而失败模式(例如令牌被盗时会发生什么,攻击者用密码列表暴力破解登录端点,或用户注销时的情况)从未被设计考虑。

认证机制必须针对对抗性条件进行设计,而不仅仅是正常流程的使用场景。

令牌过期是强制性要求。访问令牌应设置较短有效期:15分钟至1小时。刷新令牌负责处理会话延续性。短有效期的访问令牌可以限制令牌被盗时的损害窗口。

认证端点必须实施速率限制。没有速率限制的登录端点相当于向暴力破解攻击敞开大门。拥有1000万封电子邮件和密码组合列表的攻击者将系统性地尝试每一条组合。

退出登录时会话必须真正终止。如果使用有状态会话,退出登录时必须在服务器端使会话失效。如果使用JWT,需要维护令牌黑名单或使用极短的过期时间并配合刷新令牌轮换机制。

Dart:

code
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('Too many login attempts. Try again later.'),
      );
    }

    final user = await _validateCredentials(email, password);

    if (user == null) {
      return Result.failure(
        AppException.unauthorized('Invalid credentials'),
      );
    }

    // 短时效访问令牌
    final accessToken = _generateAccessToken(user, expiryMinutes: 15);

    // 更长时效的刷新令牌,服务器端存储
    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 {
    // 退出登录时在服务器端使刷新令牌失效
    await _tokenRepository.revokeRefreshToken(userId, refreshToken);
  }
}
code
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>("Too many login attempts.");

    var user = await _userService.ValidateCredentials(
        request.Email,
        request.Password);

    if (user == null)
        return Result.Failure<AuthTokens>("Invalid credentials");

    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,API 会处理这个请求。用户刚刚将自己提升为管理员。

这是一个结构设计问题。当系统在没有明确设计响应模式的情况下构建时,就会发生此类问题。一些开发者直接返回原始数据库实体,因为这样更简单。一些开发者缺乏足够的领域知识来正确构建数据层。结果是内部数据会泄露到外部响应中。

这也违反了接口隔离原则。响应接口迫使消费者接收其不应访问的数据。

每个 API 端点都必须拥有一个明确且专门设计的响应 DTO。响应 DTO 仅包含调用者被授权接收的字段。领域实体永远不会直接传递到响应中。

code
// 错误:直接返回领域实体 - 暴露内部字段
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()));
}
code
// 错误:直接返回领域模型
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网关层实现。这不是服务层的责任。网关是正确的执行点,因为它可以同时为所有服务处理速率限制,而无需每个服务重新实现该功能。

不同端点需要不同的限制规则。认证端点需要严格的限制(每5分钟每个IP地址最多5次尝试)。公共读取端点需要中等限制。对敏感资源的写操作需要严格限制。

Dart(中间件实现方式):

code
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);
  }
}
code
// 使用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)

普通用户可以访问仅应由管理员或特权角色访问的功能或端点。前端隐藏了按钮,但端点仍然存在且完全开放。

普通用户的令牌被用来调用管理员端点,调用成功。攻击者现在可以使用非管理员账户获得管理员功能。

通过隐藏实现的安全性不是真正的安全性。在UI层面隐藏管理员资源并不等于安全。

基于角色的访问控制(RBAC)要么未实现,要么实现错误。缺少功能级别的授权检查。假设是如果用户在UI中看不到管理员按钮,就无法调用管理员API。这种假设始终是错误的。任何开发工具都可以直接调用任何API端点,完全绕过UI。

每个函数在执行前都必须检查调用者的角色。这种检查必须发生在服务层,而不是用户界面层,也不能仅依赖API网关完成。网关可以验证令牌是否有效,但只有服务层知道每个具体操作所需的权限角色。

code
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;

    // role check - this must happen in the service layer
    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);
  }
}
code
[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服务层中显式验证角色,二者实现的目标一致:检查在代码中完成,而非用户界面,也非API消费者行为中完成。

6. 对敏感业务流程的无限制访问

本应设置严格防护措施的核心业务逻辑却没有任何限制。用户可以绕过业务规则,触发在当前情境下绝不应被允许的流程。

销售代理为无覆盖区域的客户创建销售订单时,创建销售的接口从未验证过是否存在覆盖。业务规则被违反。从财务角度看,这会导致损失;从运营角度看,这会引发需要数月才能解决的复杂问题。

业务逻辑未在领域层被识别和强制执行。领域驱动设计正是为此而存在:业务逻辑决定应用的成败。当领域层设计不当,业务规则未被显式编码时,用户可以绕过这些规则。

防护措施缺失是因为工程师们不知道这些防护措施本应存在。这既是技术问题,更是领域知识缺失的问题。

业务规则应属于领域层。这就是值对象和领域实体所强制执行的内容。未确认覆盖前无法创建销售订单。这条规则被编码到领域层,而非API端点或用户界面中。

code
class Sale {
  final String agentId;
  final String customerId;
  final CoverageArea coverageArea;
  final SaleStatus status;

 /think

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('无法创建成交:客户区域没有有效覆盖'), ); }

return Result.success(Sale._( agentId: agentId, customerId: customerId, coverageArea: coverageArea, status: SaleStatus.pending, )); } }

code

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>( "无法创建成交:该区域没有有效覆盖");

return Result.Success(new Sale(agentId, customerId, coverageArea)); } }

code

创建成交的端点调用领域工厂。领域工厂强制执行业务规则。由于API无法绕过工厂创建Sale对象,因此该规则无法通过API绕过。

## 7. 安全配置错误

开发人员的个人调试流程被部署到生产环境。详细的错误信息暴露了堆栈跟踪。调试端点仍然处于活动状态。宽松的CORS允许任何来源。日志中包含敏感数据。开发人员的本地配置在生产环境中运行。

当开发、预发布和生产配置之间没有隔离时,这类问题容易发生。没有流水线网关在部署到生产环境前捕获配置错误。每个开发人员管理自己的配置,这意味着每个开发人员的习惯和调试偏好都可能进入生产环境。

为每个环境设置不同的工作流程。生产环境的流水线必须包含静态分析、配置验证和代码检查,这些检查在允许合并前能捕获调试端点、宽松的CORS、详细的错误日志和暴露的敏感信息。

Dart(环境感知的错误处理):

class ErrorHandler { final Environment _environment;

ErrorHandler(this._environment);

Response handleException(Object error, StackTrace stackTrace) {

logger.error('未处理的异常', error: error, stackTrace: stackTrace);

if (_environment.isProduction) {

return Response.internalServerError( body: jsonEncode({'error': '发生内部错误'}), ); }

return Response.internalServerError( body: jsonEncode({ 'error': error.toString(), 'stackTrace': stackTrace.toString(), }), ); } }

code

app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { context.Response.StatusCode = 500; context.Response.ContentType = "application/json";

code

var error = context.Features.Get<IExceptionHandlerFeature>(); if (error != null) { // 内部记录完整错误信息 logger.LogError(error.Error, "未处理的异常"); }

// 向调用方返回通用错误信息 await context.Response.WriteAsync( JsonSerializer.Serialize(new { error = "发生内部错误" }) ); }); });

// CORS - 生产环境必须显式指定允许的来源,禁止使用通配符 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");

}); });

code

生产环境中的 CORS 配置必须显式指定允许的来源。生产环境使用通配符的 CORS 配置是严重的安全漏洞,会导致互联网上的任意网页都能使用访客凭证向您的 API 发起认证请求。

## 8. 库存管理不当

服务器上仍然运行着已被弃用的 API、已停用的端点和过时的 API 版本。没有人知道它们的存在,也没有人负责维护它们。但攻击者会发现这些端点并加以利用,因为旧端点通常比当前端点的安全防护更弱。

API 生命周期管理没有明确的负责人。端点被创建后,功能会不断变化。旧版本的端点被遗忘而不是被正式停用。随着时间推移,API 的暴露面会不断增长,这些仍然响应请求的死端点往往没有新端点所具备的安全控制措施。

必须有人负责管理 API 清单。这是正式的工程责任,而非可选实践。每个 API 端点都必须被记录、版本化,并定义明确的生命周期状态:活跃、弃用或停用。

弃用的端点应返回包含弃用日期的响应头。停用的端点必须返回 410 Gone 状态码,而不能继续处理请求。

// 弃用端点包装器 Handler deprecatedEndpoint({ required Handler handler, required DateTime removalDate, required String replacementEndpoint, }) { return (Request request) async { final response = await handler(request);

// 添加弃用头信息以便客户端迁移 return response.change(headers: { 'Deprecation': 'true', 'Sunset': HttpDate.format(removalDate), 'Link': '<$replacementEndpoint>; rel="successor-version"', 'Warning': '299 - "此端点已被弃用,将于 ${removalDate.toIso8601String()} 停用"', }); }; }

// 停用端点 Future<Response> decommissionedEndpoint(Request request) async { return Response( 410, body: jsonEncode({ 'error': '此端点已被永久移除', 'replacement': '/api/v2/accounts', }), ); }

code

// 使用 ApiVersion 属性标记弃用端点 [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\""); } }

code

// 仍会在弃用期间处理请求 return Ok(_service.GetAccount(id)); } }

// 已移除 - 永久删除 [ApiController] [Route("api/v1/legacy/accounts")] public class LegacyAccountsController : ControllerBase { [HttpGet] public IActionResult GetAll() { return StatusCode(410, new { error = "此接口已被永久移除", replacement = "/api/v2/accounts" }); } }

code

## 9. API的不安全使用

您的应用程序与第三方服务集成时,会在未经验证或缺乏防护措施的情况下直接信任其数据。无论外部API返回什么内容,您的应用程序都会直接处理这些数据。

第三方支付回调返回交易状态时,您的应用程序会直接信任该状态,而不会验证签名或校验数据结构。攻击者可以向您的回调URL发送伪造的回调请求,您的应用程序会将其视为合法请求进行处理。

默认情况下外部服务被视为可信的。没有明确定义外部服务返回的有效数据应具备什么特征。在外部服务响应与应用程序处理逻辑之间缺少验证层。

系统默认假设信任而非实际验证。

与每个外部服务的集成都必须有明确的契约定义。该契约需要明确规定有效数据的特征、必填字段以及有效范围和格式。在处理任何外部服务响应之前,必须先根据该契约进行验证。

class PaymentCallbackService { final String _webhookSecret;

PaymentCallbackService(this._webhookSecret);

Future<Result<PaymentStatus, AppException>> processCallback( Map<String, dynamic> payload, String signature, String rawBody, ) async { // 步骤1:在处理任何内容之前先验证签名 final isValid = _verifySignature(rawBody, signature, _webhookSecret); if (!isValid) { logger.warning('收到无效的webhook签名'); return Result.failure( AppException.unauthorized('无效的webhook签名'), ); }

// 步骤2:将负载结构与我们的契约进行验证 final validationResult = _validateCallbackPayload(payload); if (validationResult.isFailure) { logger.warning('无效的回调负载: ${validationResult.error}'); return Result.failure(validationResult.error!); }

// 步骤3:只有在此时才信任并处理数据 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; }

code

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); } }

code

public class PaymentCallbackService { private readonly string _webhookSecret; private readonly ILogger<PaymentCallbackService> _logger;

public async Task<Result<PaymentStatus>> ProcessCallback( string rawBody, string signature, PaymentCallbackDto payload) { // verify signature first if (!VerifySignature(rawBody, signature)) { _logger.LogWarning("Invalid webhook signature received"); return Result.Failure<PaymentStatus>("Invalid webhook signature"); }

// validate payload 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) ); } }

code

工程实践标准要求在集成前就与外部服务建立经过验证的契约。永远不要盲目信任,必须严格验证。

## 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在发起任何请求前,都必须将URL与允许的域名列表进行验证。这是设计决策。允许列表是API规范的一部分,而非临时补救措施。

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)); } }

code

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)); } }

code

## 工程漏洞:超越OWASP清单的范畴

OWASP清单涵盖了最关键和最常见的API漏洞。但工程实践经验表明,还存在其他模式可能造成严重的安全漏洞。

### 未实施域名白名单的多客户端API调用

代码和日志中暴露的秘密

开发人员会将秘密密钥、公钥和凭证留在代码中、存储库中或自己的机器上。仅仅将文件添加到.gitignore中是不够的。这些数据集必须在运行时从可信的秘密管理器获取,绝不能存储在源代码中。

组织必须使用保险库、Azure App Configuration、AWS Secrets Manager或等效系统。这必须成为工程标准,而非开发人员的个人偏好。保险库的访问模式必须标准化,使开发人员无需针对每个项目重新学习这一流程。

微服务之间未通过网关的通信

如果你正在构建微服务,服务之间的所有通信都必须被视为敏感信息。必须存在一个API网关来处理所有服务的身份验证。服务之间不应默认相互信任。每个服务间的调用都必须经过身份验证。

微服务之间的通信应使用Kafka等事件代理处理异步流,或使用mTLS处理同步流。任何微服务在未经过网关的情况下都不应直接暴露在互联网上。

缺乏组织标准的加密

组织必须制定标准化的加密算法。不是由开发人员选择的算法,不是每周推荐的算法,也不是开发人员在Stack Overflow答案中找到的任意算法。必须由工程团队做出决策,明确指定组织标准的算法类型、模式和密钥长度。

临时加密选择带来的漏洞非常严重:填充Oracle攻击、弱密钥长度、IV重复使用,以及加密失败时暴露的堆栈跟踪信息,这些都可能向调用者泄露内部实现细节。

工程控制措施:为项目提供内部构建的加密包或云函数,项目直接导入这些组件而非自行实现。开发人员使用该包,从不针对每个项目重新编写加密代码。

SQL注入

SQL注入已有25年的历史,自1998年就已存在。但这种情况仍然频繁发生,因为工程标准未被强制执行。

这种情况发生是因为开发人员使用字符串拼接构建查询,将用户输入直接拼接到SQL字符串中。解决方案是始终使用参数化查询或预处理语句。任何包含用户输入拼接的原始SQL字符串都是不可接受的。

dart
// 错误:字符串拼接 — 存在SQL注入漏洞
Future<User?> findUser(String email) async {
  final query = "SELECT * FROM users WHERE email = '$email'";
  // 攻击者输入:[email protected]' OR '1'='1
  // 生成的查询:SELECT * FROM users WHERE email = '[email protected]' 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;
}
code
// 错误的字符串拼接方式
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 意识授权来防止。
  • 破碎的认证通过合理的令牌设计和速率限制来防止。
  • BOPLA 通过显式的响应 DTO 来防止。
  • 无限制的资源消耗通过网关的速率限制来防止。
  • BFLA 通过代码中的角色检查来防止,而不是在 UI 中。
  • 敏感业务流程暴露通过领域驱动设计和合理的业务规则执行来防止。
  • 安全配置错误通过具有自动化门禁的环境特定管道来防止。
  • 不当的库存管理通过正式的 API 生命周期所有权来防止。
  • 不安全的 API 消费通过以契约优先的外部集成来防止。
  • SSRF 通过 URL 白名单作为设计决策来防止。

模式是一致的。安全不是您添加到应用程序中的东西,而是您从一开始就设计到架构中的东西。本文中的每个决策都是工程决策:这个检查应该属于架构的哪个位置?哪一层负责这个验证?领域强制执行什么?网关强制执行什么?管道捕获什么?

设计安全不是安全团队的责任,而是工程团队的责任。它始于准确理解这些漏洞是什么、它们来自哪里以及预防它们的工程标准是什么。

作为软件工程师,这种层次的思维方式确保我们的代码能够通过这些安全测试。

快乐安全编码!

合作伙伴推荐课程

(在新标签页中打开)

广告

  • Udemy 上的 AngularJS 认证:使用 Auth0 保护您的应用 Ryan Chenkie 4.5(满分5星)(2,250人评价)1小时45分钟 初学者 查看课程(在新标签页中打开)
  • Udemy 上的 RESTful Web 服务、Java、Spring Boot、Spring MVC 和 JPA Sergey Kargopolov 4.6(满分5星)(4,322人评价)1天1小时5分钟 初学者 证书 查看课程(在新标签页中打开)
  • 在 Udemy 上的课程:Master ASP.NET Core Identity in .NET10: Auth & Authorization Frank Liu 4.7 星(2,114 条评价) 7 小时 6 分钟 中级 证书 查看课程(在新标签页中打开)
  • 在 Coursera 上的课程:Async JavaScript Per Harald Borgen 4.9 星(11 条评价) 1 小时 21 分钟 中级 证书 查看课程(在新标签页中打开)

Class Central 上的更多异步编程课程

→

工程主管和高级产品工程师,拥有 7 年以上设计、构建和领导可扩展企业级软件系统和数字产品的经验。我专注于端到端系统设计,从架构和平台工程到部署,强调可扩展性、性能、可维护性和长期技术可持续性。我曾领导跨职能工程团队,制定技术战略,提升交付速度,建立工程最佳实践,并指导开发者在技术和职业上共同成长。我的经验涵盖移动、后端和平台工程,使我能够设计与业务需求同步扩展的系统。我的核心技术栈包括 Flutter、Dart、C#、.NET 和 .NET Core,拥有构建企业级应用、集成分布式服务并交付可靠生产解决方案的丰富经验。除了产品交付,我还通过构建和发布 Flutter 包、参与开源项目、倡导工程卓越以及撰写技术内容来为工程社区做贡献,让复杂的软件架构和工程概念变得易于理解。我在实际工程、软件架构和技术领导的交汇点上茁壮成长,构建弹性系统,赋能高效团队,交付能产生可衡量业务影响的技术。

如果你读到这里,请感谢作者以表达你的支持。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始