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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user