API横向越权问题的分析和解决

最近接手了一个contact service项目,主要以APIs的形式提供contacts信息的检索,contact信息包括用护profile和group信息等用户隐私信息。

contact service提供的API主要的使用场景包括:

  1. 公司给customer提供的clients(Desktop client, Mobile client, Room Devices),直接通过API获取并展示用户的profile信息。
  2. Service-to-Service提供给公司内部的产品服务,比如公司的Web站点的后端通过API获取用户的隐私数据给管理员进行管理。
  3. 公司的非产线系统使用。比如欺诈检测团队通过API检查phoneNumber是否属于用户合法电话号码,拦截非法电话利用公司的运营商账号call out的盗打电话现象。

contact service在我接手之初,已经存在了若干年,定位是一个common service,愿景是通过统一风格的API为不同产品线的业务提供contact查询。contact service的数据模型抽象的很精炼,只有三个概念:contact, group, group_member_relationship。contact信息里存用户的profile信息包括name, display name, email, phoneNumber等。group里存group信息:group name,group owner,group create time,group settings等各种属性。group_member_relationship稍微有点复杂,除了存储group和user的关系,也利用这种A belongs to B的模式存了user和user friends,user和user device,orgnaization和department等多种relationship关系,不同的关系通过type字段不同的枚举值进行区分。

虽然数据模型很简单,但contact service经过这么多年的发展已经提供了100多个支持contact search的API。分析发现这些API的产生有三个主要原因:
1. 为client特殊定制的API。因为client端能力有限需要尽可能在服务端把数据处理好,因为client直接调用contact service导致client端各种功能场景的API基本都要单独定制。
2. 因为API本身协议不够自洽而不得不为相似的查询场景做多个相近功能的API给不同的业务使用。这里面应该也存在怕出regression bug,不想或不敢动已有的API,干脆提供个新API给新业务用。
3. 某些API存在V1/V2/V3多个版本同时维护的情况,主要原因是参数过于灵活(比如参数类型是Object,支持传string,list, map, number多种类型)而方法体里兼容灵活性比较难写,所以在API path上通过版本号区分各种隐含的逻辑处理差异,然后跟不同的上游业务约定调用哪个版本的API传string还是list。

主要问题是安全,API提供的是用户的隐私信息而且API都是公网开放的(哪怕是提供给内部service的API或内部非产线的IT系统使用的API,因为某些原因也没有Ip白名单限制),API有基于JWT的authentication,但因为参数非常灵活靠跟调用方的约定来保证能work,说明代码逻辑对边界case和corner case的覆盖不足。

如何能防止横向越权:A公司查到B公司的数据或service1把service2的数据查走,是我接手项目以来要解决的一个问题。既要保证当前用户或上游业务的使用不出问题又要保证潜在的安全问题得到解决,感觉就像要排除商场里的定时炸弹还不能让商场的人流疏散,同时源源不断的有新的商家以新的形式入住商场。

通过建立安全威胁模型,review API的实现代码和业务场景,目前采取了如下方案:

  • 建立一个类似RBAC的模型解决authorization的问题,防止service1把service2的数据查走。
  • 根据三种类型的使用方式,把API进行分类,每类API规定不同的参数校验,实现隔离的同时也方便分而治之。
  • 对于面向customer的业务场景,要去必须要传客户账户Id(AccountId)并进行强制参数校验。
  • 对于非产线系统使用场景,因为本身就需要查询多个不同客户的数据,需要区分开来调用单独的API,并通过一个授权声明的流程,让调用方自己对数据泄露负责。

具体实现:
因为项目是基于springboot实现的且集成了spring seurity,所以所有的措施都是在此框架的基础上实现的。
首先,因为项目存在时间较长,API调用方也比较多,所以需要先打log确认哪些业务在使用哪些API以及是否传递了accountId。因为API都是用JWT进行authentication,所以通过打印了JWT header头里的sub字段或iss字段和对应的API path来实现。构造一个Filter继承spring-web的OncePerRequestFilter让每个HTTP请求经过且仅经过一次这个filter。这个filter通常放在所有filter之前,因为如果被别的filter拦截掉了可能就造成信息丢失。同时,考虑到HTTPServletRequest里的内容还需要被后续逻辑读取,所以还需要做下ContentCachingRequestWrapper。

public class HttpRequestLogPrinterFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);

        try {
            filterChain.doFilter(requestWrapper, responseWrapper);
        } finally {
            printHttpRequestContent(requestWrapper);
            responseWrapper.copyBodyToResponse();
        }
    }
}

 

printHttpRequestContent方法里会从JWT header, payload, URI Param, RequestBody里解析出打印所需的信息到log,然后把log收集到Elasticsearch/ClickHouse并通过Kibana/Grafana展示。

 

其次,通过日志收集和分析,确定下来哪些业务没传accountId但实际上这个API应该传accountId,通知调用方传accountId并告知对方这个API某天之后会强制校验accountId是否存在,从而规范调用方的使用方式。通过总结日志,也能分析出来哪些业务在使用哪些API,从而建立一个user=>scope=>APIs的模型:

Map<String, List<String>> userAndScopeMap = Map.of(
            "tenant1", List.of("s2s_contact_search", "internal_contact_search"),
            "tenant2", List.of("s2s_contact_search"),
            "tenant3", List.of("internal_contact_search")
    );

Map<String, List<String>> scopeAndURIsMap = Map.of(
            "s2s_contact_search", List.of("/s2s/contact/search", "/s2s/contact/seek"),
            "internal_contact_search", List.of("/internal/contact/search")
    );

Scope类似于Role,不同的tenant可以有多个scope,不同的scope对应多个API的访问权限,当有新增API时只需要把新API加到scope内,所有拥有相关scope的用户都可以访问这个API。这样就可以避免service调用了它无权限访问的API。
定义一个filer,进行jwt的校验并对通过校验的token赋予相关的权限。

class MyAuthenticationFilter extends OncePerRequestFilter {
@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    String token = request.getHeader("Authorization");
        if(StringUtils.isEmpty(token) || !token.startsWith(AUTH_PREFIX)) {
            fillInvalidResponse(response, "{error: token is empty or token format is wrong}");
        } else {
            TokenVerifyResult tokenVerifyResult = jwtVerification(token);
            if(!tokenVerifyResult.isEffective()) {
                fillInvalidResponse(response, "{error: JWT signature is invalid}");
            } else {
                String issuer = tokenVerifyResult.getIssuer();
                List<String> scopes = getScopesByJWTIssuer(issuer);
                List<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
                for(String scope : scopes) {
                    List<String> permissions = getUriPermissionByScope(scope);
                    permissions.stream().forEach(permission -> {
                        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
                        simpleGrantedAuthorities.add(simpleGrantedAuthority);
                    });
                }
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(issuer,null, simpleGrantedAuthorities);
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                filterChain.doFilter(request, response);
            }
    }
}

然后,可以实现一个AuthorizationManager进行authorization

class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
        Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();
        String uri = object.getRequest().getRequestURI();
        for(GrantedAuthority r : authorities) {
            if (uri.equals(r.getAuthority())) {
                return new AuthorizationDecision(true);
            }
        }
        return new AuthorizationDecision(false);
    }
}

对于不便统一做Authorization的API,可以在controller方法里用spring security的注解进行权限校验,比如单独拎出来的给内部IT system用的API。

@PreAuthorize("hasAuthority('/internal/contact/search')")
@RequestMapping(path = "/internal/contact/search", method = RequestMethod.GET)
public String InternalAPI() {
    return "success internal";
}

 

再次,在确定了某些API必须传accountId之后,可以在查询DB的Repository层再进行一次accountId的检查,确定ID用于了SQL做查询条件,确保只查这个accountId的数据。

在Controller层必传accountId的方法头上加上一个自定义注解,注解里只需要有一个字段标识AccountIdExist。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccountIdAPI {
    String message() default  "AccountIdExist";
}

通过AOP的方式把执行了这个方法的线程的ThreadLocal里标记下{"require":"AccountIdExist"}

@Slf4j
@Component
@Aspect
public class AccountIdExistAPIAnnotationAspect {

    @Before("@annotation(company.monkey.framework.annotation.AccountIdAPI)")
    public void setFlag(JoinPoint jp) throws NoSuchMethodException {
        MethodSignature signature = (MethodSignature) jp.getSignature();
        Method method = signature.getMethod();
        Method realMethod = jp.getTarget().getClass().getDeclaredMethod(method.getName(), method.getParameterTypes());
        AccountIdAPI accountIdAPI = realMethod.getAnnotation(AccountIdAPI.class);
        if (accountIdAPI != null) {
            ThreadHelper.put("require", accountIdAPI.message());
        }
    }
}

在DB查询的repository方法里检查ThreadLocal里是否有{"require":"AccountIdExist"},如果存在则说明这个DB查询必须要带accountId,方法里做相关的检查保证查询DB的时候正确带了accountId。

 


最后,对于反欺诈类型的必须查询所有accountId下的数据的业务使用的API的path上都必须包“internal”关键字,同时让他们签署文档声明他们知道这个API的风险,使用过程中如果导致了不该有的用户隐私数据泄露问题,他们需要但责任。

通过以上的一些措施,在不影响当前调用方使用且让调用方最小限度改变,实现了对contact service API的安全加固,排除了横向越权的安全风险。

posted @ 2025-08-03 16:32  软件心理学工程师  Views(8)  Comments(0)    收藏  举报