From 7c7e9e6571c1f702213e9ffbb40921cd5a1a786b Mon Sep 17 00:00:00 2001 From: Rui Ying Date: Tue, 26 Sep 2023 12:39:18 -0700 Subject: [PATCH] Allow non-ascii header values & add utf-8 filename fallback (#35060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Fix https://github.com/facebook/react-native/issues/31537: [Android] React Native strips non-ASCII characters from HTTP headers ## Changelog [Android] [Changed] - Allow non-ascii header values on Android and add utf-8 filename fallback in FormData Pull Request resolved: https://github.com/facebook/react-native/pull/35060 Test Plan: 1. Clone the `react-native` repo. 2. Build the rn-tester app. 3. Prepare tests 1. Add `android:usesCleartextTraffic="true"` to AndroidManifest.xml 2. Use the following code as a server: ```javascript const http = require('http'); const requestListener = function (req, res) { // raw header value console.log(req.headers['content-disposition']); // nodejs assumes the header value is ISO-8859-1 encoded console.log(Buffer.from(req.headers['content-disposition'], 'latin1').toString('utf-8')); // decode encoded header value if it's sent as UTF-8 console.log(decodeURI(req.headers['content-disposition'])); res.writeHead(200); res.end(); }; const server = http.createServer(requestListener); server.listen(3000); ``` 3. Run `adb reverse tcp:3000 tcp:3000` to connect the 3000 port on the emulator if necessary. 4. Edit `RNTesterAppShared.js` to include test code: ```javascript useEffect(() => { fetch('http://localhost:3000/', { headers: { 'Content-Type': 'multipart/form-data; charset=utf-8', 'Content-Disposition': `attachment; filename*=utf-8''${encodeURI( 'filename测试abc.jpg', )}`, }, }).then(res => { console.log(res.ok); }); fetch('http://localhost:3000/', { headers: { 'Content-Type': 'multipart/form-data; charset=utf-8', 'Content-Disposition': `attachment; filename="filename测试abc.jpg"`, }, }).then(res => { console.log(res.ok); }); }, []); ``` 5. Both requests should succeed; without the fix, the second request received by the server will not have the utf-8 characters "测试" in the header value. Reviewed By: NickGerleman Differential Revision: D40639985 Pulled By: cortinico fbshipit-source-id: 005f2481976046a92a26239ad704780ac58d4a44 --- .../Libraries/Network/FormData.js | 4 ++- .../Network/__tests__/FormData-test.js | 24 +++++++++++++++++- .../react/modules/network/HeaderUtil.java | 14 ----------- .../modules/network/NetworkingModule.java | 4 +-- .../react/modules/network/HeaderUtilTest.kt | 25 ------------------- .../modules/network/NetworkingModuleTest.java | 7 ++++-- 6 files changed, 33 insertions(+), 45 deletions(-) diff --git a/packages/react-native/Libraries/Network/FormData.js b/packages/react-native/Libraries/Network/FormData.js index d1ec8116ad4..91735c6b033 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 b708a04263d..ee741afb7c9 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 07485deffca..99ce6b0fa96 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 @@ -28,18 +28,4 @@ public class HeaderUtil { } return modified ? builder.toString() : name; } - - public static String stripHeaderValue(String value) { - StringBuilder builder = new StringBuilder(value.length()); - boolean modified = false; - for (int i = 0, length = value.length(); i < length; i++) { - char c = value.charAt(i); - if ((c > '\u001f' && c < '\u007f') || c == '\t') { - builder.append(c); - } else { - modified = true; - } - } - return modified ? builder.toString() : value; - } } 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 4b23bdde76c..ca37fb2ed65 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 @@ -761,11 +761,11 @@ public final class NetworkingModule extends NativeNetworkingAndroidSpec { 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); + headersBuilder.addUnsafeNonAscii(headerName, 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/HeaderUtilTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/HeaderUtilTest.kt index 7696779d7d6..f844afaa19e 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/HeaderUtilTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/HeaderUtilTest.kt @@ -27,46 +27,21 @@ class HeaderUtilTest { assertEquals(ALPHABET_TEST, HeaderUtil.stripHeaderName(ALPHABET_TEST)) } - @Test - fun valueStripKeepsLetters() { - assertEquals(ALPHABET_TEST, HeaderUtil.stripHeaderValue(ALPHABET_TEST)) - } - @Test fun nameStripKeepsNumbers() { assertEquals(NUMBERS_TEST, HeaderUtil.stripHeaderName(NUMBERS_TEST)) } - @Test - fun valueStripKeepsNumbers() { - assertEquals(NUMBERS_TEST, HeaderUtil.stripHeaderValue(NUMBERS_TEST)) - } - - @Test - fun valueStripKeepsSpecials() { - assertEquals(SPECIALS_TEST, HeaderUtil.stripHeaderValue(SPECIALS_TEST)) - } - @Test fun nameStripKeepsSpecials() { assertEquals(SPECIALS_TEST, HeaderUtil.stripHeaderName(SPECIALS_TEST)) } - @Test - fun valueStripKeepsTabs() { - assertEquals(TABULATION_TEST, HeaderUtil.stripHeaderValue(TABULATION_TEST)) - } - @Test fun nameStripDeletesTabs() { assertEquals(TABULATION_STRIP_EXPECTED, HeaderUtil.stripHeaderName(TABULATION_TEST)) } - @Test - fun valueStripRemovesExtraSymbols() { - assertEquals(BANNED_TEST_EXPECTED, HeaderUtil.stripHeaderValue(VALUE_BANNED_SYMBOLS_TEST)) - } - @Test fun nameStripRemovesExtraSymbols() { assertEquals(BANNED_TEST_EXPECTED, HeaderUtil.stripHeaderName(NAME_BANNED_SYMBOLS_TEST)) 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 57105e3e326..46894323847 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 @@ -482,7 +482,9 @@ public class NetworkingModuleTest { 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( @@ -521,7 +523,8 @@ public class NetworkingModuleTest { 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"); + 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); }