From e5829c4554a659a2fffad1c7ce2a6d2a91611dfa Mon Sep 17 00:00:00 2001 From: Pavel Tsiber Date: Tue, 10 Feb 2026 11:46:20 +0100 Subject: [PATCH 1/3] fix(java/feign): handle binary response types in ApiResponseDecoder The Feign library's ApiResponseDecoder routes all responses through JacksonDecoder, including binary ones (File, byte[], InputStream). This causes JsonParseException when an endpoint returns non-JSON content (e.g. PDF, ZIP, images). Add binary type detection and handling before delegating to JacksonDecoder. This applies to both direct return types and ApiResponse wrappers. Consistent with the native library fix in #21346. Closes #2486 Co-Authored-By: Claude Opus 4.6 --- .../feign/ApiResponseDecoder.mustache | 67 +++++++++++++++++-- .../client/ApiResponseDecoder.java | 67 +++++++++++++++++-- .../client/ApiResponseDecoder.java | 67 +++++++++++++++++-- .../client/ApiResponseDecoder.java | 67 +++++++++++++++++-- 4 files changed, 248 insertions(+), 20 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache index 9062b648ca18..d3afca39bb71 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache @@ -7,33 +7,90 @@ import feign.Response; import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import {{modelPackage}}.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); + } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(filename)).toFile(); + tempDir.toFile().deleteOnExit(); } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) return m.group(1); } + return null; } } diff --git a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 52ca0850b144..214a8438e238 100644 --- a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -18,33 +18,90 @@ import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openapitools.client.model.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); + } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(filename)).toFile(); + tempDir.toFile().deleteOnExit(); } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) return m.group(1); } + return null; } } diff --git a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 52ca0850b144..214a8438e238 100644 --- a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -18,33 +18,90 @@ import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openapitools.client.model.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); + } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(filename)).toFile(); + tempDir.toFile().deleteOnExit(); } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) return m.group(1); } + return null; } } diff --git a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 52ca0850b144..214a8438e238 100644 --- a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -18,33 +18,90 @@ import feign.Types; import feign.jackson.JacksonDecoder; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openapitools.client.model.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { + private static final Pattern FILENAME_PATTERN = + Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); } @Override public Object decode(Response response, Type type) throws IOException { - //Detects if the type is an instance of the parameterized class ApiResponse if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(ApiResponse.class)) { - //The ApiResponse class has a single type parameter, the Dto class itself Type responseBodyType = ((ParameterizedType) type).getActualTypeArguments()[0]; - Object body = super.decode(response, responseBodyType); + Object body = isBinaryType(responseBodyType) + ? decodeBinary(response, responseBodyType) + : super.decode(response, responseBodyType); Map> responseHeaders = Collections.unmodifiableMap(response.headers()); return new ApiResponse<>(response.status(), responseHeaders, body); + } + + if (isBinaryType(type)) { + return decodeBinary(response, type); + } + + return super.decode(response, type); + } + + private boolean isBinaryType(Type type) { + Class raw = Types.getRawType(type); + return File.class.isAssignableFrom(raw) + || byte[].class.isAssignableFrom(raw) + || InputStream.class.isAssignableFrom(raw); + } + + private Object decodeBinary(Response response, Type type) throws IOException { + Class raw = Types.getRawType(type); + if (byte[].class.isAssignableFrom(raw)) { + return response.body().asInputStream().readAllBytes(); + } + if (InputStream.class.isAssignableFrom(raw)) { + return response.body().asInputStream(); + } + return downloadToTempFile(response); + } + + private File downloadToTempFile(Response response) throws IOException { + String filename = extractFilename(response); + File file; + if (filename != null) { + java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); + file = Files.createFile(tempDir.resolve(filename)).toFile(); + tempDir.toFile().deleteOnExit(); } else { - //The response is not encapsulated in the ApiResponse, decode the Dto as normal - return super.decode(response, type); + file = Files.createTempFile("download-", "").toFile(); + } + file.deleteOnExit(); + try (InputStream is = response.body().asInputStream()) { + Files.copy(is, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return file; + } + + private String extractFilename(Response response) { + Collection dispositions = response.headers().get("Content-Disposition"); + if (dispositions == null) return null; + for (String disposition : dispositions) { + Matcher m = FILENAME_PATTERN.matcher(disposition); + if (m.find()) return m.group(1); } + return null; } } From 0150317d4d69381a1ba1e7b835dc3f2a86f592cc Mon Sep 17 00:00:00 2001 From: Pavel Tsiber Date: Tue, 10 Feb 2026 11:56:56 +0100 Subject: [PATCH 2/3] fix: address code review feedback - Sanitize Content-Disposition filename to prevent path traversal (Paths.get(filename).getFileName() strips directory components) - Add null check for response.body() to handle 204/205 empty responses - Fix regex to support quoted filenames with spaces (e.g. filename="my invoice.pdf") --- .../libraries/feign/ApiResponseDecoder.mustache | 15 ++++++++++++--- .../openapitools/client/ApiResponseDecoder.java | 15 ++++++++++++--- .../openapitools/client/ApiResponseDecoder.java | 15 ++++++++++++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache index d3afca39bb71..7eaf68cea854 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/feign/ApiResponseDecoder.mustache @@ -13,6 +13,7 @@ import java.io.InputStream; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.file.Files; +import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; @@ -25,7 +26,7 @@ import {{modelPackage}}.ApiResponse; public class ApiResponseDecoder extends JacksonDecoder { private static final Pattern FILENAME_PATTERN = - Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); @@ -58,6 +59,9 @@ public class ApiResponseDecoder extends JacksonDecoder { private Object decodeBinary(Response response, Type type) throws IOException { Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } if (byte[].class.isAssignableFrom(raw)) { return response.body().asInputStream().readAllBytes(); } @@ -71,8 +75,10 @@ public class ApiResponseDecoder extends JacksonDecoder { String filename = extractFilename(response); File file; if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); - file = Files.createFile(tempDir.resolve(filename)).toFile(); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); tempDir.toFile().deleteOnExit(); } else { file = Files.createTempFile("download-", "").toFile(); @@ -89,7 +95,10 @@ public class ApiResponseDecoder extends JacksonDecoder { if (dispositions == null) return null; for (String disposition : dispositions) { Matcher m = FILENAME_PATTERN.matcher(disposition); - if (m.find()) return m.group(1); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } } return null; } diff --git a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 214a8438e238..ed75d1731f3d 100644 --- a/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign-no-nullable/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -24,6 +24,7 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.file.Files; +import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; @@ -36,7 +37,7 @@ public class ApiResponseDecoder extends JacksonDecoder { private static final Pattern FILENAME_PATTERN = - Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); @@ -69,6 +70,9 @@ private boolean isBinaryType(Type type) { private Object decodeBinary(Response response, Type type) throws IOException { Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } if (byte[].class.isAssignableFrom(raw)) { return response.body().asInputStream().readAllBytes(); } @@ -82,8 +86,10 @@ private File downloadToTempFile(Response response) throws IOException { String filename = extractFilename(response); File file; if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); - file = Files.createFile(tempDir.resolve(filename)).toFile(); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); tempDir.toFile().deleteOnExit(); } else { file = Files.createTempFile("download-", "").toFile(); @@ -100,7 +106,10 @@ private String extractFilename(Response response) { if (dispositions == null) return null; for (String disposition : dispositions) { Matcher m = FILENAME_PATTERN.matcher(disposition); - if (m.find()) return m.group(1); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } } return null; } diff --git a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 214a8438e238..ed75d1731f3d 100644 --- a/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -24,6 +24,7 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.file.Files; +import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; @@ -36,7 +37,7 @@ public class ApiResponseDecoder extends JacksonDecoder { private static final Pattern FILENAME_PATTERN = - Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); @@ -69,6 +70,9 @@ private boolean isBinaryType(Type type) { private Object decodeBinary(Response response, Type type) throws IOException { Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } if (byte[].class.isAssignableFrom(raw)) { return response.body().asInputStream().readAllBytes(); } @@ -82,8 +86,10 @@ private File downloadToTempFile(Response response) throws IOException { String filename = extractFilename(response); File file; if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); - file = Files.createFile(tempDir.resolve(filename)).toFile(); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); tempDir.toFile().deleteOnExit(); } else { file = Files.createTempFile("download-", "").toFile(); @@ -100,7 +106,10 @@ private String extractFilename(Response response) { if (dispositions == null) return null; for (String disposition : dispositions) { Matcher m = FILENAME_PATTERN.matcher(disposition); - if (m.find()) return m.group(1); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } } return null; } From cb7ed8e47da68ed77df1ebe45c68fd4396a6fcde Mon Sep 17 00:00:00 2001 From: Pavel Tsiber Date: Tue, 10 Feb 2026 12:25:25 +0100 Subject: [PATCH 3/3] fix: regenerate feign-hc5 sample with updated ApiResponseDecoder The feign-hc5 sample was missed during the second commit's regeneration because setTemplateDir("feign") overrides the filesystem templateDir from the config, causing the generator to use embedded JAR resources. After rebuilding the JAR with the updated mustache template, the feign-hc5 sample now matches feign and feign-no-nullable. Co-Authored-By: Claude Opus 4.6 --- .../openapitools/client/ApiResponseDecoder.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java index 214a8438e238..ed75d1731f3d 100644 --- a/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java +++ b/samples/client/petstore/java/feign-hc5/src/main/java/org/openapitools/client/ApiResponseDecoder.java @@ -24,6 +24,7 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.file.Files; +import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Collections; @@ -36,7 +37,7 @@ public class ApiResponseDecoder extends JacksonDecoder { private static final Pattern FILENAME_PATTERN = - Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?"); + Pattern.compile("filename=\"([^\"]+)\"|filename=([^\\s;]+)"); public ApiResponseDecoder(ObjectMapper mapper) { super(mapper); @@ -69,6 +70,9 @@ private boolean isBinaryType(Type type) { private Object decodeBinary(Response response, Type type) throws IOException { Class raw = Types.getRawType(type); + if (response.body() == null) { + return null; + } if (byte[].class.isAssignableFrom(raw)) { return response.body().asInputStream().readAllBytes(); } @@ -82,8 +86,10 @@ private File downloadToTempFile(Response response) throws IOException { String filename = extractFilename(response); File file; if (filename != null) { + // Sanitize: strip path components to prevent path traversal + String safeName = Paths.get(filename).getFileName().toString(); java.nio.file.Path tempDir = Files.createTempDirectory("feign-download"); - file = Files.createFile(tempDir.resolve(filename)).toFile(); + file = Files.createFile(tempDir.resolve(safeName)).toFile(); tempDir.toFile().deleteOnExit(); } else { file = Files.createTempFile("download-", "").toFile(); @@ -100,7 +106,10 @@ private String extractFilename(Response response) { if (dispositions == null) return null; for (String disposition : dispositions) { Matcher m = FILENAME_PATTERN.matcher(disposition); - if (m.find()) return m.group(1); + if (m.find()) { + // Group 1: quoted filename (may contain spaces), Group 2: unquoted token + return m.group(1) != null ? m.group(1) : m.group(2); + } } return null; }