2
0
mirror of https://github.com/tenrok/axios.git synced 2026-06-17 19:21:29 +03:00

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 <cyphercodes@users.noreply.github.com>
Co-authored-by: Jay <jasonsaayman@gmail.com>
This commit is contained in:
Rayan Salhab
2026-05-06 20:15:24 +03:00
committed by GitHub
parent ac5c335b3b
commit cabead5306
10 changed files with 280 additions and 36 deletions
+24
View File
@@ -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:
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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);
});
}
+1 -33
View File
@@ -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;
+60
View File
@@ -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;
}
+35
View File
@@ -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';
+66
View File
@@ -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';
+60
View File
@@ -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) => {
+28
View File
@@ -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', () => {