Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing API Obfuscation for Java Spring Boot Applications with Swagger

Tech 3

API obfuscation is a common API security hardening measure that works by replacing human-readable endpoint paths, request parameter keys and response payload keys with mapped, meaningless strings. This prevents external malicious actors from enferring API business semantics from identifiers, reducing the risk of targeted attacks. Integrating this obfuscation logic with Swagger ensures that frontend developers still get unobfuscated API schema references for normal development and debugging, while all external runtime requests interact only with obfuscated identifiers.

Add Knife4j Dependencies

Knife4j is an enhanced Swagger UI implementation for Spring Boot projects, the following version supports single API export to Postman for testing:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>2.0.7</version>
</dependency>

Add Obfuscation Configuration Properties

Add the following entries to your application.yml configuration file, including the obfuscation toggle and excluded paths that skip obfuscation processing:

# API Documentation Config
swagger:
  enableDoc: true
  apiPathPrefix: /
  docVersion: 1.0.0
  # API Obfuscation Toggle
  obfuscation:
    enabled: false

# Paths excluded from obfuscation processing
excluded-obfuscation-paths: /v2/api-docs,/swagger,/doc.html,/webjars,/error,/favicon.ico

Swagger Core Configuration Class

Create a configuration class to initialize Swagger documents and set global public request headers:

@Configuration
@EnableKnife4j
@EnableSwagger2WebMvc
public class SwaggerDocConfig {
    @Value("${swagger.enableDoc}")
    private boolean swaggerEnabled;
    @Value("${swagger.apiPathPrefix}")
    private String apiPathPrefix;
    @Value("${swagger.docVersion}")
    private String docVersion;

    @Bean
    public Docket buildRestApiDoc() {
        return new Docket(DocumentationType.SWAGGER_2)
                .enable(swaggerEnabled)
                .apiInfo(buildApiDocInfo())
                .select()
                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
                .paths(PathSelectors.any())
                .build()
                .pathMapping(apiPathPrefix)
                .globalOperationParameters(buildGlobalHeaderParams());
    }

    private List<Parameter> buildGlobalHeaderParams() {
        List<Parameter> globalHeaders = new ArrayList<>();
        ParameterBuilder paramBuilder = new ParameterBuilder();
        
        Parameter tokenHeader = paramBuilder.name(AppConstants.AUTH_TOKEN)
                .description("User authentication token")
                .modelRef(new ModelRef("string"))
                .parameterType("header")
                .required(false)
                .defaultValue("")
                .build();
        Parameter appVersionHeader = paramBuilder.name(AppConstants.APP_VERSION_CODE)
                .description("Client application version number")
                .modelRef(new ModelRef("string"))
                .parameterType("header")
                .required(true)
                .defaultValue("")
                .build();
        Parameter clientTypeHeader = paramBuilder.name(AppConstants.CLIENT_PLATFORM)
                .description("Client platform: android/ios/web")
                .modelRef(new ModelRef("string"))
                .parameterType("header")
                .required(true)
                .defaultValue("")
                .build();
        
        globalHeaders.add(tokenHeader);
        globalHeaders.add(appVersionHeader);
        globalHeaders.add(clientTypeHeader);
        return globalHeaders;
    }

    private static String buildUsageInstructions() {
        return "<div>Request Parameter Rules: Parameters are categorized into header, query, body types. " +
                "Header parameters are passed in request headers. Query parameters use content-type `application/x-www-form-urlencoded`. " +
                "Body parameters use content-type `application/json`, note: pass the nested keys of the body parameter object directly as JSON root keys, do not wrap the entire body object as a top-level key.</div>";
    }

    private ApiInfo buildApiDocInfo() {
        return new ApiInfoBuilder()
                .title("Mobile Client API Documentation")
                .description("Mobile end API usage guide: " + buildUsageInstructions())
                .version("Version: " + docVersion)
                .build();
    }
}

Obfuscated Path Filter

Create a servlet filter to rewrite obfuscated request paths to actual backend endpoint paths, and skip processing for excluded paths:

@Slf4j
public class ObfuscatedPathFilter extends GenericFilterBean {
    private static final String EXCLUDED_PATHS = "/v2/api-docs,/swagger,/doc.html,/webjars,/error,/favicon.ico";

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String requestUri = request.getRequestURI();
        for (String excludedPath : EXCLUDED_PATHS.split(",")) {
            if (requestUri.contains(excludedPath)) {
                filterChain.doFilter(servletRequest, servletResponse);
                return;
            }
        }
        ObfuscatedRequestWrapper wrappedRequest = new ObfuscatedRequestWrapper(request);
        filterChain.doFilter(wrappedRequest, servletResponse);
    }
}

Obfuscated Request Wrapper

Since the native HttpServletRequest does not support modifying parameter values, create a wrapper clas to implement deobfuscation of parameter keys, file field names and request paths:

@Slf4j
public class ObfuscatedRequestWrapper extends HttpServletRequestWrapper {
    private final Map<String, String[]> deobfuscatedParamMap = new HashMap<>();
    private final HttpServletRequest originalRequest;
    private final Collection<Part> deobfuscatedFileParts = new ArrayList<>();

    @Override
    public Collection<Part> getParts() {
        return deobfuscatedFileParts;
    }

    public ObfuscatedRequestWrapper(HttpServletRequest request) {
        super(request);
        this.originalRequest = request;
        deobfuscateParameterKeys(request);
        deobfuscateFileFieldNames(request);
    }

    @Override
    public String getRequestURI() {
        return ObfuscationUtils.deobfuscatePath(originalRequest.getRequestURI());
    }

    @Override
    public StringBuffer getRequestURL() {
        StringBuffer urlBuffer = new StringBuffer();
        String scheme = originalRequest.getScheme();
        int port = originalRequest.getServerPort();
        port = port < 0 ? 80 : port;
        
        urlBuffer.append(scheme).append("://").append(originalRequest.getServerName());
        if ((scheme.equals("http") && port != 80) || (scheme.equals("https") && port != 443)) {
            urlBuffer.append(":").append(port);
        }
        urlBuffer.append(getRequestURI());
        return urlBuffer;
    }

    private void deobfuscateParameterKeys(HttpServletRequest request) {
        Map<String, String[]> originalParams = request.getParameterMap();
        originalParams.forEach((obfuscatedKey, values) -> {
            String plainKey = ObfuscationUtils.deobfuscate(obfuscatedKey, AppConstants.OBFUSCATION_PARAM_SALT);
            deobfuscatedParamMap.put(plainKey, values);
        });
    }

    private void deobfuscateFileFieldNames(HttpServletRequest request) {
        try {
            String contentType = request.getContentType();
            if (contentType == null || !contentType.toLowerCase(Locale.ENGLISH).startsWith(FileUploadBase.MULTIPART)) {
                return;
            }
            Collection<Part> originalParts = request.getParts();
            if (CollectionUtils.isEmpty(originalParts)) return;
            for (Part part : originalParts) {
                Object fileItem = ReflectUtil.getFieldValue(part, "fileItem");
                String obfuscatedFieldName = (String) ReflectUtil.getFieldValue(fileItem, "fieldName");
                if (obfuscatedFieldName == null) {
                    deobfuscatedFileParts.add(part);
                    continue;
                }
                String plainFieldName = ObfuscationUtils.deobfuscate(obfuscatedFieldName, AppConstants.OBFUSCATION_PARAM_SALT);
                ReflectUtil.setFieldValue(fileItem, "fieldName", plainFieldName);
                deobfuscatedFileParts.add(part);
            }
        } catch (Exception e) {
            log.error("Failed to deobfuscate file upload field names", e);
        }
    }

    public ObfuscatedRequestWrapper(HttpServletRequest request, Map<String, Object> extraParams) {
        this(request);
        addExtraParams(extraParams);
    }

    public void addExtraParams(Map<String, Object> extraParams) {
        extraParams.forEach(this::addSingleParameter);
    }

    @Override
    public Enumeration<String> getParameterNames() {
        return deobfuscatedParamMap.isEmpty() ? super.getParameterNames() : Collections.enumeration(deobfuscatedParamMap.keySet());
    }

    @Override
    public String getParameter(String name) {
        String[] values = deobfuscatedParamMap.get(name);
        return values == null || values.length == 0 ? null : values[0];
    }

    @Override
    public String[] getParameterValues(String name) {
        return deobfuscatedParamMap.get(name);
    }

    public void addSingleParameter(String key, Object value) {
        if (value == null) return;
        if (value instanceof String[]) {
            deobfuscatedParamMap.put(key, (String[]) value);
        } else if (value instanceof String) {
            deobfuscatedParamMap.put(key, new String[]{(String) value});
        } else {
            deobfuscatedParamMap.put(key, new String[]{String.valueOf(value)});
        }
    }
}

Register Filter in Spring Security

For projects based on the RuoYi framework, register the obfuscation filter in the Spring Security configuration class:

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authRegistry = httpSecurity.authorizeRequests();
    permitAllUrl.getUrls().forEach(url -> {
        if (enableApiObfuscation) {
            String obfuscatedPath = ObfuscationUtils.obfuscatePath(url);
            authRegistry.antMatchers(obfuscatedPath).permitAll();
        } else {
            authRegistry.antMatchers(url).permitAll();
        }
    });

    httpSecurity
            .csrf().disable()
            .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            .authorizeRequests()
            .antMatchers(enableApiObfuscation ? ObfuscationUtils.obfuscatePath("/api/user/register") : "/api/user/register").anonymous()
            .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
            .antMatchers("/webjars/**", "/*/api-docs", "/druid/**").permitAll()
            .antMatchers("/swagger-resources/**", "/swagger-resources/configuration/ui", "/swagger-resources/configuration/security").permitAll()
            .anyRequest().authenticated()
            .and()
            .headers().frameOptions().disable();
    
    httpSecurity.logout().logoutUrl("/signOut").logoutSuccessHandler(logoutSuccessHandler);
    httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    httpSecurity.addFilterBefore(corsFilter, AppJwtAuthenticationTokenFilter.class);
    httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    if (enableApiObfuscation) {
        httpSecurity.addFilterBefore(new ObfuscatedPathFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

Response Payload Obfuscation Advice

Use ResponseBodyAdvice to obfuscate response payload keys before the response is sent too the client:

@RestControllerAdvice
@Slf4j
public class ObfuscatedResponseBodyAdvice implements ResponseBodyAdvice<Object> {
    @Value("${swagger.obfuscation.enabled:true}")
    private Boolean enableApiObfuscation;

    @Override
    public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @Override
    @Nullable
    public Object beforeBodyWrite(@Nullable Object body, MethodParameter methodParameter, MediaType mediaType,
                                  Class<? extends HttpMessageConverter<?>> converterType,
                                  ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        String requestUri = ServletUtils.getRequest().getRequestURI();
        if (!enableApiObfuscation || isExcludedFromObfuscation(requestUri)) {
            stopWatch.stop();
            log.debug("Response obfuscation skipped for uri: {}, cost: {}ms", requestUri, stopWatch.getLastTaskTimeMillis());
            return body;
        }
        Object obfuscatedBody = obfuscateResponsePayloadKeys(body);
        stopWatch.stop();
        log.debug("Response obfuscation completed for uri: {}, cost: {}ms", requestUri, stopWatch.getLastTaskTimeMillis());
        return obfuscatedBody;
    }
    
    private Object obfuscateResponsePayloadKeys(Object body) {
        if (Objects.isNull(body)) return null;
        JSONObject responseJson = JSON.parseObject(JSON.toJSONString(body, JSONWriter.Feature.WriteMapNullValue));
        Object dataNode = responseJson.get("data");
        if (dataNode instanceof CharSequence || dataNode instanceof Boolean || dataNode instanceof Number || Objects.isNull(dataNode)) {
            return responseJson;
        }
        String obfuscatedData = ApiObfuscationJsonHandler.obfuscateJsonPayload(JSON.toJSONString(dataNode, JSONWriter.Feature.WriteMapNullValue));
        if (JSONUtil.isJsonObj(obfuscatedData)) {
            responseJson.put("data", JSON.parseObject(obfuscatedData));
        } else if (JSONUtil.isJsonArray(obfuscatedData)) {
            responseJson.put("data", JSON.parseArray(obfuscatedData));
        } else {
            responseJson.put("data", obfuscatedData);
        }
        return responseJson;
    }

    private static boolean isExcludedFromObfuscation(String uri) {
        String excludedPathsConfig = GlobalConfig.getConfig("excluded-obfuscation-paths");
        for (String excludedPath : excludedPathsConfig.split(",")) {
            if (uri.contains(excludedPath)) return true;
        }
        return false;
    }
}

Request Payload Deobfuscation Advice

Use RequestBodyAdvice to deobfuscate request payload keys before they are parsed by the controller:

@RestControllerAdvice
@Slf4j
public class DeobfuscatedRequestBodyAdvice extends RequestBodyAdviceAdapter {
    @Value("${swagger.obfuscation.enabled:true}")
    private Boolean enableApiObfuscation;

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return enableApiObfuscation;
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter methodParameter,
                                           Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        String requestUri = ServletUtils.getRequest().getRequestURI();
        if (isExcludedFromObfuscation(requestUri)) return inputMessage;
        
        String requestBodyStr = readRequestBodyAsString(inputMessage.getBody());
        log.debug("Obfuscated request body for uri {}: {}", requestUri, requestBodyStr);
        if (Strings.isEmpty(requestBodyStr)) return inputMessage;
        
        String deobfuscatedBody = ApiObfuscationJsonHandler.deobfuscateJsonPayload(requestBodyStr);
        log.debug("Deobfuscated request body for uri {}: {}", requestUri, deobfuscatedBody);
        InputStream deobfuscatedStream = new ByteArrayInputStream(deobfuscatedBody.getBytes(StandardCharsets.UTF_8));
        inputMessage.getHeaders().remove("Content-Length");
        
        return new HttpInputMessage() {
            @Override
            public HttpHeaders getHeaders() {
                return inputMessage.getHeaders();
            }
            @Override
            public InputStream getBody() {
                return deobfuscatedStream;
            }
        };
    }

    private String readRequestBodyAsString(InputStream inputStream) {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[1024];
            int readLength;
            while ((readLength = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, readLength);
            }
            return outputStream.toString(StandardCharsets.UTF_8.name());
        } catch (Exception e) {
            log.error("Failed to read request body", e);
            return "";
        }
    }

    private static boolean isExcludedFromObfuscation(String uri) {
        String excludedPathsConfig = GlobalConfig.getConfig("excluded-obfuscation-paths");
        for (String excludedPath : excludedPathsConfig.split(",")) {
            if (uri.contains(excludedPath)) return true;
        }
        return false;
    }
}

JSON Payload Obfuscation Utility Class

Utility class for recursive obfuscation and deobfuscation of JSON object keys:

public class ApiObfuscationJsonHandler {
    private static JSONObject obfuscateJsonKeys(JSONObject sourceJson) {
        JSONObject outputJson = new JSONObject();
        for (String key : sourceJson.keySet()) {
            Object value = sourceJson.get(key);
            String obfuscatedKey = ObfuscationUtils.obfuscate(key, AppConstants.OBFUSCATION_PARAM_SALT);
            if (value instanceof JSONObject) {
                outputJson.put(obfuscatedKey, obfuscateJsonKeys((JSONObject) value));
            } else if (value instanceof JSONArray) {
                outputJson.put(obfuscatedKey, obfuscateArrayElementKeys((JSONArray) value));
            } else {
                outputJson.put(obfuscatedKey, value);
            }
        }
        return outputJson;
    }

    private static JSONArray obfuscateArrayElementKeys(JSONArray sourceArray) {
        JSONArray outputArray = new JSONArray();
        for (int i = 0; i < sourceArray.size(); i++) {
            Object item = sourceArray.get(i);
            if (item instanceof JSONObject) {
                outputArray.add(obfuscateJsonKeys((JSONObject) item));
            } else {
                outputArray.add(item);
            }
        }
        return outputArray;
    }

    private static JSONObject deobfuscateJsonKeys(JSONObject sourceJson) {
        JSONObject outputJson = new JSONObject();
        for (String obfuscatedKey : sourceJson.keySet()) {
            Object value = sourceJson.get(obfuscatedKey);
            String plainKey = ObfuscationUtils.deobfuscate(obfuscatedKey, AppConstants.OBFUSCATION_PARAM_SALT);
            if (value instanceof JSONObject) {
                outputJson.put(plainKey, deobfuscateJsonKeys((JSONObject) value));
            } else if (value instanceof JSONArray) {
                outputJson.put(plainKey, deobfuscateArrayElementKeys((JSONArray) value));
            } else {
                outputJson.put(plainKey, value);
            }
        }
        return outputJson;
    }

    private static JSONArray deobfuscateArrayElementKeys(JSONArray sourceArray) {
        JSONArray outputArray = new JSONArray();
        for (int i = 0; i < sourceArray.size(); i++) {
            Object item = sourceArray.get(i);
            if (item instanceof JSONObject) {
                outputArray.add(deobfuscateJsonKeys((JSONObject) item));
            } else {
                outputArray.add(item);
            }
        }
        return outputArray;
    }

    public static String deobfuscateJsonPayload(String json) {
        if (StringUtils.isEmpty(json) || !JSONUtil.isJson(json)) return json;
        if (JSONUtil.isJsonObj(json)) {
            return deobfuscateJsonKeys(JSON.parseObject(json)).toString();
        } else if (JSONUtil.isJsonArray(json)) {
            return deobfuscateArrayElementKeys(JSON.parseArray(json)).toString();
        }
        return json;
    }

    public static String obfuscateJsonPayload(String json) {
        if (StringUtils.isEmpty(json) || !JSONUtil.isJson(json)) return json;
        if (JSONUtil.isJsonObj(json)) {
            return obfuscateJsonKeys(JSON.parseObject(json)).toString();
        } else if (JSONUtil.isJsonArray(json)) {
            return obfuscateArrayElementKeys(JSON.parseArray(json)).toString();
        }
        return json;
    }
}

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.