diff --git a/packages/react-native/Libraries/Network/FormData.js b/packages/react-native/Libraries/Network/FormData.js index d1ec8116ad41b9..91735c6b033b19 100644 --- a/packages/react-native/Libraries/Network/FormData.js +++ b/packages/react-native/Libraries/Network/FormData.js @@ -82,7 +82,9 @@ class FormData { // content type (cf. web Blob interface.) if (typeof value === 'object' && !Array.isArray(value) && value) { if (typeof value.name === 'string') { - headers['content-disposition'] += '; filename="' + value.name + '"'; + headers['content-disposition'] += `; filename="${ + value.name + }"; filename*=utf-8''${encodeURI(value.name)}`; } if (typeof value.type === 'string') { headers['content-type'] = value.type; diff --git a/packages/react-native/Libraries/Network/__tests__/FormData-test.js b/packages/react-native/Libraries/Network/__tests__/FormData-test.js index b708a04263d083..ee741afb7c983b 100644 --- a/packages/react-native/Libraries/Network/__tests__/FormData-test.js +++ b/packages/react-native/Libraries/Network/__tests__/FormData-test.js @@ -48,7 +48,29 @@ describe('FormData', function () { type: 'image/jpeg', name: 'photo.jpg', headers: { - 'content-disposition': 'form-data; name="photo"; filename="photo.jpg"', + 'content-disposition': + 'form-data; name="photo"; filename="photo.jpg"; filename*=utf-8\'\'photo.jpg', + 'content-type': 'image/jpeg', + }, + fieldName: 'photo', + }; + expect(formData.getParts()[0]).toMatchObject(expectedPart); + }); + + it('should return blob with the correct utf-8 handling', function () { + formData.append('photo', { + uri: 'arbitrary/path', + type: 'image/jpeg', + name: '测试photo.jpg', + }); + + const expectedPart = { + uri: 'arbitrary/path', + type: 'image/jpeg', + name: '测试photo.jpg', + headers: { + 'content-disposition': + 'form-data; name="photo"; filename="测试photo.jpg"; filename*=utf-8\'\'%E6%B5%8B%E8%AF%95photo.jpg', 'content-type': 'image/jpeg', }, fieldName: 'photo', diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/HeaderUtil.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/HeaderUtil.java index 07485deffca4c6..e26ee0ca4406c9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/HeaderUtil.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/HeaderUtil.java @@ -7,14 +7,35 @@ package com.facebook.react.modules.network; +import java.lang.reflect.Method; +import okhttp3.Headers; + /** - * The class purpose is to weaken too strict OkHttp restriction on http headers. See: - * https://github.com/square/okhttp/issues/2016 Auth headers might have an Authentication - * information. It is better to get 401 from the server in this case, rather than non descriptive - * error as 401 could be handled to invalidate the wrong token in the client code. + * The class purpose is to provide compatibility among OkHttp versions on adding non-ascii header values. + * + * For v3.12.0 or higher, we can use the `addUnsafeAscii` method to add non-ascii header values. + * See: https://square.github.io/okhttp/changelogs/changelog_3x/#version-3120 + * We need to use reflection to call this method, as it is not available in older versions. + * Remove reflection once the internal version of OkHttp is updated to v3.12.0 or higher. + * + * For other versions, we need to strip non-ascii header values. + * See: https://github.com/square/okhttp/issues/2016 + * Auth headers might have an Authentication information. It is better to get 401 from the server + * in this case, rather than non descriptive error as 401 could be handled to invalidate the wrong + * token in the client code. */ public class HeaderUtil { + public static Method addUnsafeNonAsciiMethod = null; + + static { + try { + addUnsafeNonAsciiMethod = Headers.Builder.class.getMethod("addUnsafeNonAscii", String.class, String.class); + } catch (NoSuchMethodException e) { + // Ignore + } + } + public static String stripHeaderName(String name) { StringBuilder builder = new StringBuilder(name.length()); boolean modified = false; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java index 1c4b373f126aa1..53f6f3edabdb86 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java @@ -25,6 +25,8 @@ import com.facebook.react.module.annotations.ReactModule; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashSet; @@ -760,11 +762,24 @@ public void removeListeners(double count) {} return null; } String headerName = HeaderUtil.stripHeaderName(header.getString(0)); - String headerValue = HeaderUtil.stripHeaderValue(header.getString(1)); + String headerValue = header.getString(1); if (headerName == null || headerValue == null) { return null; } - headersBuilder.add(headerName, headerValue); + + if (HeaderUtil.addUnsafeNonAsciiMethod != null) { + try { + // Use reflection to call addUnsafeNonAscii because it's not available in + // older versions of OkHttp that are used internally. + HeaderUtil.addUnsafeNonAsciiMethod.invoke(headersBuilder, headerName, headerValue); + } catch (IllegalAccessException | InvocationTargetException e) { + // Stripping non-ascii characters is needed for the regular `add` method. + headersBuilder.add(headerName, HeaderUtil.stripHeaderValue(headerValue)); + } + } else { + // Stripping non-ascii characters is needed for the regular `add` method. + headersBuilder.add(headerName, HeaderUtil.stripHeaderValue(headerValue)); + } } if (headersBuilder.get(USER_AGENT_HEADER_NAME) == null && mDefaultUserAgent != null) { headersBuilder.add(USER_AGENT_HEADER_NAME, mDefaultUserAgent); diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java index a49e6ebc2c857a..8181a215c5db51 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.java @@ -25,6 +25,7 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.common.StandardCharsets; import com.facebook.react.common.network.OkHttpCallUtil; +import com.facebook.react.modules.network.HeaderUtil; import java.io.InputStream; import java.util.Arrays; import java.util.List; @@ -502,7 +503,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { JavaOnlyArray.from( Arrays.asList( JavaOnlyArray.of("content-type", "image/jpg"), - JavaOnlyArray.of("content-disposition", "filename=photo.jpg")))); + JavaOnlyArray.of("content-disposition", "filename=\"测试photo.jpg\"; filename*=utf-8''%E6%B5%8B%E8%AF%95photo.jpg")))); formData.pushMap(imageBodyPart); mNetworkingModule.sendRequest( @@ -538,7 +539,17 @@ public Object answer(InvocationOnMock invocation) throws Throwable { assertThat(bodyHeaders.get(0).get("content-disposition")).isEqualTo("user"); assertThat(bodyRequestBody.get(0).contentType()).isNull(); assertThat(bodyRequestBody.get(0).contentLength()).isEqualTo("locale".getBytes().length); - assertThat(bodyHeaders.get(1).get("content-disposition")).isEqualTo("filename=photo.jpg"); + + if (HeaderUtil.addUnsafeNonAsciiMethod != null) { + // We're on a version of OkHttp that supports non-ascii header values + // so we should expect the non-ascii characters to be preserved. + assertThat(bodyHeaders.get(1).get("content-disposition")).isEqualTo("filename=\"测试photo.jpg\"; filename*=utf-8''%E6%B5%8B%E8%AF%95photo.jpg"); + } else { + // We're on a version of OkHttp that doesn't support non-ascii header values + // so we should expect the non-ascii characters to be stripped. + assertThat(bodyHeaders.get(1).get("content-disposition")).isEqualTo("filename=\"photo.jpg\"; filename*=utf-8''%E6%B5%8B%E8%AF%95photo.jpg"); + } + assertThat(bodyRequestBody.get(1).contentType()).isEqualTo(MediaType.parse("image/jpg")); assertThat(bodyRequestBody.get(1).contentLength()).isEqualTo("imageUri".getBytes().length); }