← 返回蜂巢洞察

API漏洞的“工程学分析”:深入探讨OWASP列出的十大最常见API安全风险

大多数安全文章读起来都像威胁报告。它们从外部角度描述各种漏洞:攻击者会采取什么行动、这些漏洞会造成什么影响,以及全球范围内有多少系统会受到影响。这样的信息确实很有参考价值,但它们并不能帮助工程师构建更安全的系统。 但这篇文章有所不同。它从内部角度分析OWASP API安全十大漏洞中的每一个:是哪些工程决策导致了这些漏洞的出现,违反了哪些架构原则,以及应该采取什么样的工程标准才能防止这类漏洞被应用到生产环境中。 我在受监管的金融环境中从事过多年大规模生产级应用程序的开发工作。这份列表中提到的漏洞并非理论上的概念,我在实际系统中见过其中的大部分。有些漏洞在我将其投入生产之前就发现了;而有些则在我开

大多数安全文章读起来都像威胁报告。它们从外部角度描述各种漏洞:攻击者会采取什么行动、这些漏洞会造成什么影响,以及全球范围内有多少系统会受到影响。这样的信息确实很有参考价值,但它们并不能帮助工程师构建更安全的系统。

但这篇文章有所不同。它从内部角度分析OWASP API安全十大漏洞中的每一个:是哪些工程决策导致了这些漏洞的出现,违反了哪些架构原则,以及应该采取什么样的工程标准才能防止这类漏洞被应用到生产环境中。

我在受监管的金融环境中从事过多年大规模生产级应用程序的开发工作。这份列表中提到的漏洞并非理论上的概念,我在实际系统中见过其中的大部分。有些漏洞在我将其投入生产之前就发现了;而有些则在我开始工作时就已经存在了。事实上,每一个这些漏洞都是可以避免的……但不是通过使用安全工具,而是通过严格的工程规范和纪律来防止它们的发生。

这篇文章正是从这一角度来进行分析的。

目录

先决条件

在阅读本文之前,您需要具备以下基础知识:

  • 能够使用任何语言或框架开发API

  • 对认证机制有基本的了解,包括令牌和会话的概念

  • 了解数据库查询的基本结构

  • 掌握软件架构的相关概念,如层次结构、服务以及网关等

你并不需要具备安全方面的背景知识。这篇文章是从工程技术的角度来解释各种安全漏洞的,并非从安全分析人员的视角来进行阐述。

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

OWASP全称为开放网络应用安全项目。它是一个非营利性组织,为工程师和各类机构提供免费的安全指导资料。OWASP API安全十大漏洞列表是一份会定期更新的清单,其中列出了在全球各生产系统中发现的最严重的API安全漏洞。

这份清单并非基于理论编撰而成,而是根据真实发生的安全事件、渗透测试的结果以及来自各行各业生产系统的漏洞报告整理而成的。这份清单上的每一项漏洞都曾在实际中导致数据泄露、经济损失或监管处罚。

对于那些负责开发API的工程师来说,了解这份清单是必不可少的——这是他们必须掌握的基础知识。

1. 对象级授权机制失效(BOLA)

什么是对象级授权机制失效?

用户已经完成了身份验证,并且拥有有效的令牌,但他们仅仅通过修改请求中的标识符,就能访问本不应属于他们的数据。

GET /api/accounts/12345/transactions

假设用户A已经通过了身份验证,并且拥有账户12345。如果他们将账户编号修改为99999,那么:

GET /apiaccounts/99999/transactions

如果API返回了用户B的交易记录,那就说明对象级授权机制失效了。虽然用户已经通过了身份验证,但API并没有验证该用户是否确实拥有他们所请求的资源。

这是目前世界上最常见的API安全漏洞。由于这种漏洞非常容易被引入代码中,而且在代码审查过程中也很容易被忽略,因此它始终位列OWASP的十大漏洞榜单之首。

背后的工程原因

对象级授权机制失效的根本原因在于:人们将授权过程视为一个二进制问题——用户是否已经通过了身份验证?只有“是”或“否”这两种答案。API仅仅检查用户是否拥有有效的令牌,而从不进一步验证该用户是否确实拥有他们所请求的资源。

目前的认证机制并不能区分不同的用户身份;它只能证明你的身份,却无法确认你要求获取的资源是否属于你。

工程层面的解决方案

授权过程必须发生在服务层,而不仅仅是API网关层面。API网关可以验证令牌的有效性,但只有服务层才能确定该用户是否确实拥有他们所请求的资源。

Dart语言示例(服务层实现):

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('账户未找到'));
    }

    // 在返回数据之前验证账户所有权
    if (account.ownerId != currentUserId) {
      return Resultfailure(
        AppException.forbidden('无权访问该账户'),
      );
    }

    // 获取用户的所有交易记录
    final transactions = await _repository.findByAccountId(accountId);
    return Result.success(transactions);
  }
}

C#:

public async Task> GetTransactions(
    string accountId,
    ClaimsPrincipal.currentUser)
{
    var userId = currentUser.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var account = await _repository.FindAccountByIdAsync(accountId);

    if (account == null)
        return Result.Failure:>("账户未找到");

    // 所有权检查——这个步骤绝对不能跳过
    if (account.OwnerId != userId)
        return Result.Failure。>("访问权限被拒绝");

    var transactions = await _repository.FindByAccountIdAsync(accountId);
    return Result.Success(transactions);
}

对于任何访问特定资源的请求,都必须进行所有权检查。不仅仅是在首次加载数据或执行写入操作时,而是每一个请求都必须要进行这项检查。

2. 认证机制故障

什么是认证机制故障

当用于识别请求发起者的机制存在缺陷时,就会发生认证机制故障。这些缺陷可能包括:令牌有效期设置不合理、没有对认证接口实施速率限制,或者用户在注销后会继续保持登录状态等等。

工程设计上的缺陷

一旦认证功能能够正常使用,工程师们就会认为这个问题已经得到了解决。他们完成登录功能的实现、获取到令牌,然后就不再进一步考虑可能出现的故障情况了。例如,当令牌被窃取时会发生什么,或者当攻击者用大量密码尝试登录时系统会如何反应,这些情况根本就没有被纳入设计考虑范围。

认证机制必须能够适应各种恶意攻击场景,而不仅仅是在正常使用情况下才能正常运行。

工程上的补救措施

令牌必须设置有效期。访问令牌应该具有较短的生命周期,通常为15分钟到1小时。通过刷新令牌可以保证会话的连续性。如果令牌的有效期过长,那么当令牌被窃取时,造成的损失就会更大。

必须对认证接口实施速率限制。如果没有速率限制,登录接口就会成为遭受暴力攻击的目标。拥有1000万组电子邮件和密码组合的攻击者,会系统地尝试每一组组合来登录系统。

用户注销后,会话必须被及时终止。如果使用的是有状态的会话,那么在用户注销时,服务器必须立即使该会话失效。如果使用的是JWT令牌,就需要维护一个令牌黑名单,或者设置较短的令牌有效期,并通过刷新令牌来管理会话状态。

Dart:

class AuthService {
  final TokenRepository _tokenRepository;
  final RateLimiter _rateLimiter;

  AuthService(this._tokenRepository, this._rateLimiter);

  Future> login(
    String email,
    String password,
    String ipAddress,
  ) async {
    // 通过IP地址实施速率限制,以防止暴力攻击
    final isAllowed = await _rateLimiter.checkLimit(
      key: 'login:$ipAddress',
      maxAttempts: 5,
      windowSeconds: 300,
    );

    if (!is Allowed) {
      return Result.Failure(
        AppException RATELimited('登录尝试次数过多。请稍后再试。'),
      );
    }

    final user = await _validateCredentials(email, password);

    if (user == null) {
      return Result FAILURE(
        AppException.unauthorized('凭证无效'),
      );
    }

    // 生成短期访问令牌
    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 logout(String userId, String refreshToken) async {
    // 在用户注销后,立即使服务器端的刷新令牌失效
    await _tokenRepository.revokeRefreshToken(userId, refreshToken);
  }
}

C#:

public async Task> Login(
    LoginRequest request,
    string ipAddress)
{
    var isAllowed = await _rateLimiter.CheckLimit(
        key: $"login:{ipAddress}",
        maxAttempts: 5,
        windowSeconds: 300);

    if (!is Allowed)
        return Result.Failure("登录尝试次数过多。");

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

    if (user == null)
        return Result.Failure("凭证无效");

    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. 对象属性级别的授权机制缺陷

什么是这种缺陷

API返回的数据量超出了用户的实际需求。一些内部标志、管理员权限以及敏感信息也会被包含在响应数据中。更糟糕的是,API允许用户设置某些本不应被允许设置的字段,从而导致用户能够修改那些他们根本无权触动的资料。

例如,当用户查询自己的个人资料时,响应结果中可能会包含isAdmin: false、internalAccountScore: 742或fraudRiskLevel: "low"这些信息。但这些内容根本不应该出现在面向用户的响应数据中。

又或者,当用户尝试更新自己的个人资料时,请求数据中可能会包含"role": "admin"这样的字段。如果API接受了这个请求并进行了处理,那么用户就自动获得了管理员权限。

这种缺陷产生的原因

这实际上是一个架构设计上的问题。当系统在构建时没有经过精心设计的响应数据结构,就会出现这种情况。有些开发人员为了图方便,直接返回了数据库中的原始数据;还有一些人缺乏相关的领域知识,无法正确地组织数据结构,结果导致内部数据被泄露到了外部响应中。

此外,这种设计也违反了“接口隔离原则”。响应接口强制接收方接受一些他们本不应能够获取的数据。

解决这个缺陷的方法

每个API端点都必须有明确且经过精心设计的响应数据结构。响应数据结构中只应包含调用方被授权访问的字段,数据库中的原始数据绝不应该直接出现在响应结果中。

Dart:

// 错误示例:直接返回数据库中的原始数据,导致内部信息泄露
Future getProfile(Request request) async {
  final user = await _userRepository.findById(userId);
  return Response.ok(jsonEncode(user.toJson())); // 所有数据都会被暴露出来
}

// 正确示例:使用明确的响应数据结构,只包含调用方应该看到的信息
class UserProfileResponse {
  final String id;
  final String firstName;
  final String lastName;
  final String email;

  constUserProfileResponse({
    required this.id,
    required this.firstName,
    required this.lastName,
    required this.email,
  });

  Map toJson() => {
    'id': id,
    'first_name': firstName,
    'last_name': lastName,
    'email': email,
    // 'isAdmin', 'internalScore', 'fraudRiskLevel' — 这些字段不应该出现在响应中
  };
}

Future 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 GetProfile(string userId)
{
    var user = await _repository.FindByIdAsync(userId);
    return Ok(user); 
}

// 正确的做法:使用明确的响应数据对象
public record UserProfileResponse(
    string Id,
    string FirstName,
    string LastName,
    string Email);

public async Task GetProfile(string userId)
{
    var user = await _repository.FindByIdAsync(userId);

    var response = newUserProfileResponse(
        user.Id,
        user.FirstName,
        user.LastName,
        user.Email
        // IsAdmin、InternalScore、FraudRiskLevel这些字段不应被公开
    );

    return Ok(response);
}

对于传入的请求,应使用仅包含用户被允许修改的字段的响应数据对象。在更新操作中,绝不要直接将请求数据绑定到领域实体上。

4. 无限制的资源消耗

什么是无限制的资源消耗

不存在任何速率限制机制。攻击者可以通过爬取、暴力破解或拒绝服务攻击等方式,每分钟向关键API发送数千次请求。这些API会毫无阻碍地接收所有请求,不会对请求量进行任何限制。

工程设计上的缺陷

速率限制通常被视为可选功能,或者被推迟到“需要扩展系统规模时”或“发现滥用行为时”再处理。然而,等到滥用行为真正出现时,损害已经造成了。如果没有速率限制机制,金融API可能会被用于暴力破解以获取有效账户信息,或者被用来爬取价格数据,从而导致系统瘫痪。

工程解决方案

速率限制必须在API网关层实施,也就是在请求到达服务层之前。这不是任何具体服务的责任——因为网关可以同时为所有服务应用这一限制机制,而无需每个服务都自行重新实现它。

不同的接口端点需要不同的限制规则。认证接口端点需要严格的限制(例如每IP地址5分钟内只能尝试5次请求);公共读取接口端点则需要适度的限制;而对敏感资源进行写入操作时,则必须实施严格的速率限制。

Dart(中间件实现方式):

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 = _getLimitForEndpointendpoint);
      final isAllowed = await _limiter.checkLimit(
        key: '$clientIp:$endpoint',
        maxRequests: limit.maxRequests,
        windowSeconds: limit.windowSeconds,
      );

      if (!isAllowed) {
        return Response(
          429,
          body: jsonEncode({'error': '速率限制已达到上限'},
          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 Login(LoginRequest request) { }

[EnableRateLimiting("standard")]
[HttpGet("transactions")]
public async Task GetTransactions() { }

5. 功能级授权机制失效问题

什么是功能级授权机制失效

普通用户能够访问那些本应只有管理员或具有特殊权限的用户才能访问的功能或接口。虽然前端界面隐藏了这些按钮,但这些接口仍然存在,且可以被任意访问。

使用普通用户的令牌来调用管理员接口时,系统依然会正常响应。这样一来,攻击者就可以用非管理员账户获得管理员权限。

“通过隐蔽来实现安全”并不是真正的安全措施。仅仅在用户界面层面隐藏管理员资源,并不能真正提高安全性。

技术层面的缺陷

RBAC(基于角色的访问控制)要么根本没有被实现,要么实现方式有误。在功能层面上缺乏必要的授权检查。人们认为,如果用户在用户界面上看不到管理员按钮,那么他们就无法调用管理员API——但这种假设是错误的。任何开发工具都可以直接调用任何API接口,完全绕过用户界面。

技术层面的解决方案

每个功能在执行之前都必须检查调用者的角色。这种检查必须在服务层进行,而不能在用户界面层或API网关层面完成。网关可以验证令牌的有效性,但只有服务层才能确定具体操作需要哪些角色权限。

Dart:

class UserManagementService {
  final AuthContext _authContext;
  final UserRepository _repository;

  UserManagementService(this._authContext, this._repository);

  Future> deleteUser(String targetUserId) async {
    final currentUser = _authContext.currentUser;

    // 进行角色检查——这一操作必须在服务层完成
    if (!currentUser.hasRole(UserRole.admin)) {
      return Result.Failure(
        AppException.forbidden('此操作需要管理员权限'),
      );
    }

    await _repository.deleteUser(targetUserId);
    return Result.success(null);
  }

  Future, AppException>> getAllUsers() async {
    final.currentUser = _authContext.currentUser;

    if (!currentUser.hasRole(UserRole.admin)) {
      return Result.Failure(
        AppException.forbidden('需要管理员权限'),
      );
    }

    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 DeleteUser(string userId)
    {
        await _service.DeleteUser(userId);
        return NoContent();
    }

    [HttpGet]
    [Authorize(Roles = "Admin")]
    public async Task GetAllUsers()
    {
        var users = await _service.GetAllUsers();
        return Ok(users);
    }
}

在C#中,通过属性级别进行角色验证;而在Dart中,则在服务层进行明确的角色验证——这两种方式其实都能达到相同的目的:验证操作是在代码中进行的,而不是在用户界面中,更不是在API调用者的行为逻辑中进行的。

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

什么是这种问题

那些本应受到严格约束的核心业务逻辑实际上并没有这些约束。用户可以绕过这些业务规则,从而触发一些在他们当前的情况下根本不应该被允许发生的操作流程。

例如,某个销售人员在某个没有服务覆盖范围的地区为某位客户创建了一笔销售记录,而负责处理该业务的接口根本没有验证该地区是否具有服务覆盖范围。这样一来,就违反了业务规则。从财务角度来看,这会导致损失;从运营角度来看,这种问题可能会耗费数月时间才能解决。

工程层面的缺陷

业务逻辑在领域层并没有被明确界定,也没有得到有效的执行。领域驱动设计正是为了解决这个问题而存在的:如果业务逻辑出现错误,就会影响整个应用程序的正常运行。当领域层的设计不够完善,且业务规则没有以明确的方式被编码进去时,用户就可以绕过这些规则。

之所以会出现这种问题,是因为工程师们原本并不清楚应该在这些地方设置约束机制。这既是一个技术问题,也是一个与领域知识相关的问题。

工程层面的补救措施

业务规则应当被纳入领域层中。值对象和领域实体正是用来确保这些规则得到遵守的。例如,如果没有确认客户所在地区具有服务覆盖范围,就不得创建销售记录——这一规则是编码在领域模型中的,而不是在API接口或用户界面中。

Dart:

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

  Sale._({
    required this.agentId,
    required this.customerId,
    required thiscoverageArea,
    required this.status,
  });

  // 在创建销售记录时就会执行这一业务规则:如果没有服务覆盖范围,就不能创建销售记录
  static Result 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: SaleStatuspending,
    ));
  }
}
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 Create(
        string agentId,
        string customerId,
        CoverageArea coverageArea)
    {
        if (!coverageArea.HasActiveCoverage)
            return Result.Failure( 
                "无法创建销售记录:该区域没有有效的覆盖范围" 
            );

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

用于创建销售记录的接口会调用相应的工厂模式类。这个工厂模式会确保业务规则得到执行;由于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(),
      }),
    );
  }
}

C#:


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

        var error = context.Features.Get();
        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");
    });
});

在生产环境中,CORS配置必须明确指定允许的请求来源地址。如果使用通配符,将会导致严重的安全漏洞,因为任何互联网上的网页都可能利用这些通配符,使用访问者的身份信息向你的API发送请求。

8. 库存管理不当

什么是库存管理不当

已被弃用的API、已停止使用的接口端点,以及仍在服务器上运行的过时API版本——这些资源往往无人知晓,也没有人负责维护它们。然而攻击者会找到这些存在但被忽视的资源,并利用它们进行攻击,因为旧版本的接口端点通常比新版本的安全性更低。

工程管理上的缺陷

API的生命周期管理并没有被明确指定为任何特定人员的职责。接口端点会被创建出来,其功能也会发生变化,但旧的版本往往会被遗忘,而不是被正式停用。随着时间的推移,API的系统结构会变得越来越复杂,其中那些仍然在响应请求的旧接口端点,往往缺乏与新版本相同的安全防护措施。

解决工程管理问题的方法

必须有人负责管理所有的API资源。这是一项正式的工程职责,而不是可选项。每一个API接口端点都必须被记录在案,要有明确的版本信息,并且其生命周期也要被明确界定——是处于活跃状态、已被弃用,还是已经停止使用。

对于已被弃用的接口端点,应该在其响应头中注明废弃日期;而对于那些已经被正式停用的接口端点,必须返回410错误码,不再继续处理任何请求。

Dart语言示例: // 用于标记已被弃用的接口端点的包装类 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 decommissionedEndpoint(Request request) async { return Response( 410, body: jsonEncode({ 'error': '此接口端点已被永久删除', 'replacement': '/api/v2accounts', }), ); }

C#:

// 使用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");
        ResponseHeaders.Add("Sunset", "Sat, 01 Jan 2027 00:00:00 GMT");
        ResponseHeaders.Add("Link", "</api/v2accounts/{id}>; rel=\"successor-version\"");

        // 在弃用期间仍会处理这些请求
        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/v2accounts" });
    }
}

9. 不安全的API使用方式

什么是不安全的API使用方式

您的应用程序会与第三方服务进行集成,并且在不进行任何验证或采取任何防护措施的情况下直接信任这些服务提供的数据。无论外部API返回什么数据,您的应用程序都会直接对其进行处理。

例如,当第三方支付服务返回交易状态信息时,您的应用程序会不加验证地直接接受这些信息;即使攻击者发送了伪造的回调请求,您的应用程序也会将其视为合法请求并进行处理。

这种设计上的缺陷

默认情况下,外部服务被视为可信任的。对于外部服务提供的有效数据格式,并没有明确的规范或约定;在外部服务的响应数据与应用程序的处理逻辑之间,也不存在任何验证机制。

人们只是假设这些服务是可靠的,而并没有真正进行验证。

如何解决这个问题

任何与第三方服务的集成都必须有明确的规范或约定。这些规范应该明确说明有效数据的具体格式、所需字段以及允许的范围和格式。在对外部服务的响应数据进行任何处理之前,都必须先根据这些规范进行验证。

Dart:

class PaymentCallbackService {
  final String _webhookSecret;

  PaymentCallbackService(this._webhookSecret);

  Future> processCallback(
    Map payload,
    String signature,
    String rawBody,
  ) async {
    // 第一步:在处理数据之前先验证签名
    final isValid = _verifySignature(rawBody, signature, _webhookSecret);
    if (!isValid) {
      logger.warning('接收到的Webhook签名无效');
      return Result.Failure(
        AppException.unauthorized('Webhook签名无效'),
      );
    }

    // 第二步:根据约定验证数据结构
    final validationResult = _validateCallbackPayload(payload);
    if (validationResult.isFailure) {
      loggerwarning('回调数据无效:${validationResult.error}`);
      return ResultFAILURE(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 _validateCallbackPayload(
    Map payload,
  ) {
    if (!payload.containsKey('transaction_id')) {
      return Result.Failure(
        AppException.validation('缺少必需字段:transaction_id'),
      );
    }
    if (!payload.containsKey('status')) {
      return ResultFAILURE(
        AppExceptionvalidation('缺少必需字段:status'),
      );
    }
    final validStatuses = {'success', 'failed', 'pending'};
    if (!validStatuses.contains(payload['status'])) {
      return Result FAILURE(
        AppException.validation('状态值无效:${payload['status']}'),
      );
    }
    return Result.success(null);
  }
}

C#:

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

    public async Task-result> ProcessCallback(
        string rawBody,
        string signature,
        PaymentCallbackDto payload)
    {
        // 首先验证签名
        if (!VerifySignature(rawBody, signature))
        {
            _logger.LogWarning("接收到的Webhook签名无效");
            return Result.Failure("Webhook签名无效");
        }

        // 然后验证请求数据
        if (string.IsNullOrEmpty(payload.TransactionId))
            return Result.Failure("缺少transaction_id字段");

        var validStatuses = new[] { "success", "failed", "pending" };
        if (!validStatuses.Contains(payload.Status))
            return Result.Failure(payload.Status, true));
    }

    private bool VerifySignature(string body, string signature)
    {
        using var hmac = new HMACSHA256 Encoding.UTF8.GetBytes(_webhookSecret));
        var hash = hmaccomputeHash(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,都可能成为攻击的目标。因此,在决定允许使用任意URL作为输入时,就必须同时制定严格的验证机制。

工程上的解决方案

<任何接受URL作为输入的API,在发出请求之前都必须先将该URL与允许使用的域名列表进行比对验证。这是一种设计上的规定——允许使用的域名列表是该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('URL格式无效')); } // 在生产环境中,回调地址必须使用HTTPS协议 if (urischeme != 'https') { return Result FAILURE( AppException(validation('回调地址必须使用HTTPS'), ); } // 确保该域名在允许列表中 if (!_allowedDomains.contains(uri.host)) { return Result.Failure( AppExceptionvalidation( '回调地址的域名不在允许列表中', ), ); } // 明确禁止使用内部IP地址作为回调地址 if (_isInternalAddress(uri.host)) { return Result FAILURE( AppException.validation('回调地址不能指向内部IP地址'), ); } 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 AllowedDomains = new() { "api.yourpartner.com", "hooks.yourintegration.com", "callbacks.trustedservice.io" }; public async Task-result> RegisterCallbackUrl(string url) { var validationResult = ValidateCallbackUrl(url); if (!validationResult.IsSuccess) return result.Failure(validationResult.Error); await _repository.SaveCallbackUrl(url); return result.Success(true); } private result ValidateCallbackUrl(string url) { if (!Uri.TryCreate(url, UriKindAbsolute, out var uri)) return result FAILURE("URL格式无效"); if (urischeme != "https") return result.Failure("回调地址必须使用HTTPS"); if (!AllowedDomains.Contains(uri.Host)) return resultFAILURE("该域名不在允许列表中"); if (IsInternalAddress(uri.Host)) return result_FAILURE("不允许使用内部IP地址作为回调地址"); 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上找到的解决方案。必须通过正式的工程决策来明确指定哪种加密算法、哪种加密模式以及多长的密钥长度才是组织所认可的标准。

那些基于随意选择的加密方案所带来的安全漏洞十分严重:比如填充值攻击、过短的密钥长度、IV字段的重复使用,以及在加密失败时泄露的内部实现细节等等。

为了解决这些问题,应该规定:对于那些需要被其他项目导入使用的加密功能,应由团队统一开发并提供;开发人员不应该为每个项目都重新编写加密代码。

SQL注入攻击

SQL注入攻击已经存在了25年,从1998年开始就被人们所熟知。然而这种漏洞至今仍然会发生,原因在于工程规范并没有得到有效执行。

之所以会出现这种情况,是因为开发人员使用字符串连接的方式来构建查询语句,他们会将用户输入的数据直接拼接到SQL字符串中。正确的解决办法就是始终使用参数化查询或预编译语句;绝对不能使用将用户输入数据直接拼接进原始SQL字符串的方法。

Dart语言示例:

// 错误的字符串连接方式——会导致SQL注入漏洞
Future 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 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 FindUser(string email)
{
    var query = $"SELECT * FROM Users WHERE Email = '{email}'";
    return await _context.Users.FromSqlRaw(query).FirstOrDefaultAsync();
}

// 使用EF Core进行参数化查询,可以有效防止注入攻击
public async Task FindUser(string email)
{
    return await _contextUsers
        .Where(u => u.Email == email)
        .FirstOrDefaultAsync();
}

// 在必要时也可以使用原始的参数化SQL语句
public async Task FindUserRaw(string email)
{
    return await _context.Users
        .FromSqlRaw("SELECT * FROM Users WHERE Email = {0}", email)
        .FirstOrDefaultAsync();
}

这种漏洞其实源于一些核心的工程决策,而这些决策本应该受到严格的规范和合规性原则的约束。这些原则有助于在项目和组织中建立统一的开发标准。

这种漏洞也可以在CI/CD流程中得到控制。如果CI/CD管道中的静态分析无法检测出这种将用户输入数据直接拼接进SQL字符串的行为,那么团队就应该添加相应的检查机制。这类漏洞绝对不应该被允许出现在生产环境中。

结论

这份列表中的每一个漏洞都有一个共同点:它们都是可以预防的。预防这些漏洞的方法不是在事后使用安全工具,而是在设计和开发阶段就严格遵守工程规范。

  • BOLA漏洞可以通过在服务层实施基于ID的授权机制来预防。

  • 认证失败的问题可以通过设计合理的令牌机制和实施速率限制来避免。

  • BOPLA漏洞可以通过使用明确的响应数据结构来防止。

  • 通过在网关处实施速率限制,可以防止资源被无限制地消耗。

  • BFLA漏洞可以通过在代码中进行检查来避免,而不是在用户界面中进行控制。

  • 通过采用领域驱动设计并严格执行业务规则,可以防止敏感的业务流程信息被暴露。

  • 通过为不同的环境配置专门的管道,并设置自动化的检查机制,可以避免安全配置错误的发生。

  • 通过明确规定API的生命周期管理责任,可以防止库存管理不善的问题。

  • 通过采用以合同为依据的外部集成方式,可以确保API的使用是安全的。

  • 通过将允许访问的URL列表进行明文指定,可以从设计层面预防SSRF攻击。

这种模式是一致的。安全性并不是什么可以事后添加到应用程序中的东西,而应该是从一开始就融入系统架构之中的要素。本文中提到的每一个决策实际上都属于工程决策:这些安全检查功能应该被放置在系统的哪个层次中?哪一层负责执行相应的验证工作?业务逻辑部分应该遵循哪些安全规范?网关应该承担哪些安全职责?数据流处理流程又应该捕捉哪些潜在的安全风险? “通过设计来实现安全性”并不是安全团队的责任,而是工程团队的职责。而要履行这一职责,首先就必须清楚地了解这些安全漏洞的本质、它们的来源,以及应采取什么样的工程措施来预防它们。 作为软件工程师,这种具有前瞻性的思考方式能够确保我们的代码能够顺利通过各种安全测试。 祝大家编写出更加安全的代码!

相关文章

技术实践

API认证与授权机制:深入探讨其工作原理、权衡因素以及可能出现的故障类型

每种API都包含某种形式的认证机制。但仅仅拥有认证机制与正确实施这一机制是两回事。 我曾经见过一些生产环境中的系统:在这些系统中,JWT令牌没有过期时间限制;有些系统的API密钥被硬编码在源代码中,并被提交到了公共代码仓库中;还有一些系统在使用OAuth重定向URL时使用了通配符;还有些系统在处理金融相关的API时使用了基本认证机制,而这些API本应通过HTTPS进行传输,但却没有人对此进行检查。 上述每一个情况都构成了潜在的安全漏洞,随时可能被恶意利用。 这些问题的产生,并非源于粗心的工程师。而是那些虽然了解各种认证机制的工作原理,却不了解它们在出现故障时会如何导致问题发生的工程师。没有人告

阅读全文
技术实践

如何向英国税务海关总署的“数字化纳税”API提交季度更新报告

每年有四次,所有参与“税收数字化”计划的个体经营者和房东都必须向英国税务海关总署提交一份收入与支出的汇总报表。 2026-27纳税年度的第一个截止日期是8月7日,这个期限已经过去;第二个截止日期则是11月7日。每份汇总报表都需要通过软件向英国税务海关总署发送一次API请求,而本教程正是专门讲解如何正确完成这些请求操作的。 您无需事先阅读任何其他资料即可跟随本教程进行操作。每次进行“税收数字化”集成时所需的一次性设置信息(包括沙箱应用程序、OAuth 2.0访问令牌以及防欺诈相关配置)已在下方的简要总结中列出,同时每段代码示例中也都会使用到一些辅助函数。 如果您想深入了解这些设置步骤,我曾在 之

阅读全文
技术实践

为什么绝不应该在客户端代码中嵌入Gemini API密钥(以及Firebase AI逻辑是如何解决这个问题的)

生成式人工智能的快速发展促使成千上万的网页开发者在他们的应用程序中添加智能功能。 人们的第一反应通常是从浏览器直接调用Gemini API的SDK。然而,这种做法存在严重的安全风险:会将你的API密钥暴露给外界。 在本文中,你将了解到为什么将原始的Gemini API密钥提供给客户端是危险的,Firebase AI Logic的代理架构是如何解决这一问题的,以及Firebase App Check又是如何弥补单独使用代理所无法解决的问题。 阅读完本文后,你将能够搭建出一个可正常使用的生产环境配置:一个受到保护的AI Logic客户端、一个配置正确的App Check流程(其中包含调试令牌),以

阅读全文
技术实践

如何连接英国税务海关总局的“数字化纳税”API:初学者指南

如果你为那些需要缴纳英国税款的用户开发软件,那么迟早你会需要与 HMRC 进行沟通。 针对所得税的“数字化纳税”计划于2026年4月6日正式实施,目前自我雇用人以及年收入超过50,000英镑的房东必须遵守这一规定。到2027年4月,这一收入门槛将降至30,000英镑;而到2028年4月,则进一步降低到20,000英镑。因此,在未来两年内,受该规定影响的人群数量将会大幅增加。 实际上,这意味着许多小型企业现在都需要能够将其财务数据发送给HMRC的软件,而有人就需要开发这样的软件。 当你第一次阅读HMRC提供的开发者文档时,会看到一大堆缩写词:MTD、ITSA、OAuth权限范围、防欺诈头部信息以

阅读全文