From cabead530614ec86761d92d81af2783aafeb9d01 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Wed, 6 May 2026 20:15:24 +0300 Subject: [PATCH] fix: preserve Unicode headers for request interceptors (#10850) * fix: preserve Unicode headers for request interceptors * fix: sanitize adapter-bound header values * perf: use regex header value sanitization * chore: added tests to cover regression cases * docs: update with latest changes --------- Co-authored-by: cyphercodes Co-authored-by: Jay --- docs/pages/advanced/headers.md | 24 ++++++++++ lib/adapters/fetch.js | 3 +- lib/adapters/http.js | 3 +- lib/adapters/xhr.js | 3 +- lib/core/AxiosHeaders.js | 34 +------------- lib/helpers/sanitizeHeaderValue.js | 60 ++++++++++++++++++++++++ tests/browser/headers.browser.test.js | 35 ++++++++++++++ tests/unit/adapters/fetch.test.js | 66 +++++++++++++++++++++++++++ tests/unit/adapters/http.test.js | 60 ++++++++++++++++++++++++ tests/unit/axiosHeaders.test.js | 28 ++++++++++++ 10 files changed, 280 insertions(+), 36 deletions(-) create mode 100644 lib/helpers/sanitizeHeaderValue.js diff --git a/docs/pages/advanced/headers.md b/docs/pages/advanced/headers.md index 9dc12f43..6364f123 100644 --- a/docs/pages/advanced/headers.md +++ b/docs/pages/advanced/headers.md @@ -124,6 +124,30 @@ api.interceptors.request.use((config) => { }); ``` +## Unicode header values + +`AxiosHeaders` preserves non-control Unicode characters in header values so request interceptors can transform them before the request is sent. CR/LF and other C0 control bytes are still stripped at set time to prevent header injection. + +Adapters sanitize header values to byte-safe (HT, printable ASCII, and Latin-1 supplement) right before handing them to the platform — Node's `http.request`, the browser's `XMLHttpRequest.setRequestHeader`, and `fetch`'s `Headers`. If a header value contains characters outside that range and you have not encoded it, those characters are stripped, which can produce an empty value on the wire. + +If you need to send non-ASCII data in a header, encode it in a request interceptor: + +```js +api.interceptors.request.use((config) => { + if (config.headers.has('X-Name')) { + config.headers.set('X-Name', encodeURIComponent(config.headers.get('X-Name'))); + } + return config; +}); + +await api.get('/api/data', { + headers: { + 'X-Name': '请求用户', + }, +}); +// → request is sent with X-Name: %E8%AF%B7%E6%B1%82%E7%94%A8%E6%88%B7 +``` + ## Reading response headers Response headers are available on `response.headers` as an `AxiosHeaders` instance. All header names are lower-cased: diff --git a/lib/adapters/fetch.js b/lib/adapters/fetch.js index 324be7aa..e0b2600e 100644 --- a/lib/adapters/fetch.js +++ b/lib/adapters/fetch.js @@ -13,6 +13,7 @@ import resolveConfig from '../helpers/resolveConfig.js'; import settle from '../core/settle.js'; import estimateDataURLDecodedBytes from '../helpers/estimateDataURLDecodedBytes.js'; import { VERSION } from '../env/data.js'; +import { toByteStringHeaderObject } from '../helpers/sanitizeHeaderValue.js'; const DEFAULT_CHUNK_SIZE = 64 * 1024; @@ -284,7 +285,7 @@ const factory = (env) => { ...fetchOptions, signal: composedSignal, method: method.toUpperCase(), - headers: headers.normalize().toJSON(), + headers: toByteStringHeaderObject(headers.normalize()), body: data, duplex: 'half', credentials: isCredentialsSupported ? withCredentials : undefined, diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 252c7d87..d1d4c39e 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -25,6 +25,7 @@ import readBlob from '../helpers/readBlob.js'; import ZlibHeaderTransformStream from '../helpers/ZlibHeaderTransformStream.js'; import callbackify from '../helpers/callbackify.js'; import shouldBypassProxy from '../helpers/shouldBypassProxy.js'; +import { toByteStringHeaderObject } from '../helpers/sanitizeHeaderValue.js'; import { progressEventReducer, progressEventDecorator, @@ -766,7 +767,7 @@ export default isHttpAdapterSupported && const options = Object.assign(Object.create(null), { path, method: method, - headers: headers.toJSON(), + headers: toByteStringHeaderObject(headers), agents: { http: config.httpAgent, https: config.httpsAgent }, auth, protocol, diff --git a/lib/adapters/xhr.js b/lib/adapters/xhr.js index fd43e91a..25636776 100644 --- a/lib/adapters/xhr.js +++ b/lib/adapters/xhr.js @@ -8,6 +8,7 @@ import platform from '../platform/index.js'; import AxiosHeaders from '../core/AxiosHeaders.js'; import { progressEventReducer } from '../helpers/progressEventReducer.js'; import resolveConfig from '../helpers/resolveConfig.js'; +import { toByteStringHeaderObject } from '../helpers/sanitizeHeaderValue.js'; const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined'; @@ -156,7 +157,7 @@ export default isXHRAdapterSupported && // Add headers to the request if ('setRequestHeader' in request) { - utils.forEach(requestHeaders.toJSON(), function setRequestHeader(val, key) { + utils.forEach(toByteStringHeaderObject(requestHeaders), function setRequestHeader(val, key) { request.setRequestHeader(key, val); }); } diff --git a/lib/core/AxiosHeaders.js b/lib/core/AxiosHeaders.js index bc411f59..235b6f79 100644 --- a/lib/core/AxiosHeaders.js +++ b/lib/core/AxiosHeaders.js @@ -2,46 +2,14 @@ import utils from '../utils.js'; import parseHeaders from '../helpers/parseHeaders.js'; +import { sanitizeHeaderValue } from '../helpers/sanitizeHeaderValue.js'; const $internals = Symbol('internals'); -const INVALID_HEADER_VALUE_CHARS_RE = /[^\x09\x20-\x7E\x80-\xFF]/g; - -function trimSPorHTAB(str) { - let start = 0; - let end = str.length; - - while (start < end) { - const code = str.charCodeAt(start); - - if (code !== 0x09 && code !== 0x20) { - break; - } - - start += 1; - } - - while (end > start) { - const code = str.charCodeAt(end - 1); - - if (code !== 0x09 && code !== 0x20) { - break; - } - - end -= 1; - } - - return start === 0 && end === str.length ? str : str.slice(start, end); -} - function normalizeHeader(header) { return header && String(header).trim().toLowerCase(); } -function sanitizeHeaderValue(str) { - return trimSPorHTAB(str.replace(INVALID_HEADER_VALUE_CHARS_RE, '')); -} - function normalizeValue(value) { if (value === false || value == null) { return value; diff --git a/lib/helpers/sanitizeHeaderValue.js b/lib/helpers/sanitizeHeaderValue.js new file mode 100644 index 00000000..0462fc0e --- /dev/null +++ b/lib/helpers/sanitizeHeaderValue.js @@ -0,0 +1,60 @@ +'use strict'; + +import utils from '../utils.js'; + +function trimSPorHTAB(str) { + let start = 0; + let end = str.length; + + while (start < end) { + const code = str.charCodeAt(start); + + if (code !== 0x09 && code !== 0x20) { + break; + } + + start += 1; + } + + while (end > start) { + const code = str.charCodeAt(end - 1); + + if (code !== 0x09 && code !== 0x20) { + break; + } + + end -= 1; + } + + return start === 0 && end === str.length ? str : str.slice(start, end); +} + +// The control-code ranges are intentional: header sanitization strips C0/DEL bytes. +// eslint-disable-next-line no-control-regex +const INVALID_UNICODE_HEADER_VALUE_CHARS = new RegExp('[\\u0000-\\u0008\\u000a-\\u001f\\u007f]+', 'g'); +// eslint-disable-next-line no-control-regex +const INVALID_BYTE_STRING_HEADER_VALUE_CHARS = new RegExp('[^\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+', 'g'); + +function sanitizeValue(value, invalidChars) { + if (utils.isArray(value)) { + return value.map((item) => sanitizeValue(item, invalidChars)); + } + + return trimSPorHTAB(String(value).replace(invalidChars, '')); +} + +export const sanitizeHeaderValue = (value) => + sanitizeValue(value, INVALID_UNICODE_HEADER_VALUE_CHARS); + +export const sanitizeByteStringHeaderValue = (value) => + sanitizeValue(value, INVALID_BYTE_STRING_HEADER_VALUE_CHARS); + +export function toByteStringHeaderObject(headers) { + const byteStringHeaders = Object.create(null); + + utils.forEach(headers.toJSON(), (value, header) => { + byteStringHeaders[header] = sanitizeByteStringHeaderValue(value); + }); + + return byteStringHeaders; +} diff --git a/tests/browser/headers.browser.test.js b/tests/browser/headers.browser.test.js index 306c466c..67368da3 100644 --- a/tests/browser/headers.browser.test.js +++ b/tests/browser/headers.browser.test.js @@ -118,6 +118,41 @@ describe('headers (vitest browser)', () => { await finishRequest(request, promise); }); + it('should allow request interceptors to encode Unicode header values before XHR sends them', async () => { + const instance = axios.create({ adapter: 'xhr' }); + + instance.interceptors.request.use((config) => { + config.headers.oprtName = encodeURIComponent(config.headers.oprtName); + return config; + }); + + const promise = instance.get('/foo', { + headers: { + oprtName: '请求用户', + }, + }); + await new Promise((resolve) => setTimeout(resolve)); + const request = getLastRequest(); + + expect(request.requestHeaders.oprtName).toBe(encodeURIComponent('请求用户')); + + await finishRequest(request, promise); + }); + + it('should sanitize unencoded Unicode headers before passing them to XHR', async () => { + const promise = axios.get('/foo', { + adapter: 'xhr', + headers: { + oprtName: '请求用户', + }, + }); + const request = getLastRequest(); + + expect(request.requestHeaders.oprtName).toBe(''); + + await finishRequest(request, promise); + }); + it('should respect common Content-Type header', async () => { const instance = axios.create(); instance.defaults.headers.common['Content-Type'] = 'application/custom'; diff --git a/tests/unit/adapters/fetch.test.js b/tests/unit/adapters/fetch.test.js index 5661a072..0afdd6a3 100644 --- a/tests/unit/adapters/fetch.test.js +++ b/tests/unit/adapters/fetch.test.js @@ -81,6 +81,72 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => } }); + it('should allow request interceptors to encode Unicode header values before fetch sends them', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + oprtName: req.headers.oprtname, + }) + ); + }, + { + port: SERVER_PORT, + } + ); + + const instance = axios.create({ + baseURL: LOCAL_SERVER_URL, + adapter: 'fetch', + }); + + instance.interceptors.request.use((config) => { + config.headers.oprtName = encodeURIComponent(config.headers.oprtName); + return config; + }); + + try { + const { data } = await instance.get('/', { + headers: { + oprtName: '请求用户', + }, + }); + + assert.strictEqual(data.oprtName, encodeURIComponent('请求用户')); + } finally { + await stopHTTPServer(server); + } + }); + + it('should sanitize unencoded Unicode headers before passing them to fetch', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + xTest: req.headers['x-test'], + }) + ); + }, + { + port: SERVER_PORT, + } + ); + + try { + const { data } = await fetchAxios.get(`${LOCAL_SERVER_URL}/`, { + headers: { + 'x-test': '请求用户', + }, + }); + + assert.strictEqual(data.xTest, ''); + } finally { + await stopHTTPServer(server); + } + }); + describe('responses', () => { it('should support text response type', async () => { const originalData = 'my data'; diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index 1b1ef534..3e623365 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -185,6 +185,66 @@ describe('supports http with nodejs', () => { } }); + it('should allow request interceptors to encode Unicode header values before Node sends them', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + oprtName: req.headers.oprtname, + }) + ); + }, + { port: SERVER_PORT } + ); + + const instance = axios.create({ proxy: false }); + + instance.interceptors.request.use((config) => { + config.headers.oprtName = encodeURIComponent(config.headers.oprtName); + return config; + }); + + try { + const { data } = await instance.get(`http://localhost:${server.address().port}/`, { + headers: { + oprtName: '请求用户', + }, + }); + + assert.strictEqual(data.oprtName, encodeURIComponent('请求用户')); + } finally { + await stopHTTPServer(server); + } + }); + + it('should sanitize unencoded Unicode request headers before passing them to Node', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + xTest: req.headers['x-test'], + }) + ); + }, + { port: SERVER_PORT } + ); + + try { + const { data } = await axios.get(`http://localhost:${server.address().port}/`, { + proxy: false, + headers: { + 'x-test': '请求用户', + }, + }); + + assert.strictEqual(data.xTest, ''); + } finally { + await stopHTTPServer(server); + } + }); + it('should parse the timeout property', async () => { const server = await startHTTPServer( (req, res) => { diff --git a/tests/unit/axiosHeaders.test.js b/tests/unit/axiosHeaders.test.js index 7cbb77d5..14821e30 100644 --- a/tests/unit/axiosHeaders.test.js +++ b/tests/unit/axiosHeaders.test.js @@ -118,6 +118,34 @@ describe('AxiosHeaders', () => { assert.deepStrictEqual(headers.get('set-cookie'), ['safe=1', 'unsafe=1Injected: true']); }); + + // Regression: https://github.com/axios/axios/issues/10849 + // Non-control Unicode header values must round-trip through set/get so + // request interceptors can encode them (e.g. encodeURIComponent) before + // the adapter sanitizes to byte-safe values at send time. + it('should preserve non-control Unicode characters in header values', () => { + const headers = new AxiosHeaders(); + + headers.set('x-name', '请求用户'); + + assert.strictEqual(headers.get('x-name'), '请求用户'); + }); + + it('should preserve non-control Unicode characters in array header values', () => { + const headers = new AxiosHeaders(); + + headers.set('x-names', ['请求用户', 'naïve', 'プロジェクト']); + + assert.deepStrictEqual(headers.get('x-names'), ['请求用户', 'naïve', 'プロジェクト']); + }); + + it('should still strip CR/LF from Unicode header values to prevent header injection', () => { + const headers = new AxiosHeaders(); + + headers.set('x-name', '请求\r\nInjected: true用户'); + + assert.strictEqual(headers.get('x-name'), '请求Injected: true用户'); + }); }); it('should support uppercase name mapping for names overlapped by class methods', () => {