mirror of
https://github.com/tenrok/axios.git
synced 2026-06-20 20:00:40 +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
|
## Reading response headers
|
||||||
|
|
||||||
Response headers are available on `response.headers` as an `AxiosHeaders` instance. All header names are lower-cased:
|
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 settle from '../core/settle.js';
|
||||||
import estimateDataURLDecodedBytes from '../helpers/estimateDataURLDecodedBytes.js';
|
import estimateDataURLDecodedBytes from '../helpers/estimateDataURLDecodedBytes.js';
|
||||||
import { VERSION } from '../env/data.js';
|
import { VERSION } from '../env/data.js';
|
||||||
|
import { toByteStringHeaderObject } from '../helpers/sanitizeHeaderValue.js';
|
||||||
|
|
||||||
const DEFAULT_CHUNK_SIZE = 64 * 1024;
|
const DEFAULT_CHUNK_SIZE = 64 * 1024;
|
||||||
|
|
||||||
@@ -284,7 +285,7 @@ const factory = (env) => {
|
|||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
signal: composedSignal,
|
signal: composedSignal,
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
headers: headers.normalize().toJSON(),
|
headers: toByteStringHeaderObject(headers.normalize()),
|
||||||
body: data,
|
body: data,
|
||||||
duplex: 'half',
|
duplex: 'half',
|
||||||
credentials: isCredentialsSupported ? withCredentials : undefined,
|
credentials: isCredentialsSupported ? withCredentials : undefined,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import readBlob from '../helpers/readBlob.js';
|
|||||||
import ZlibHeaderTransformStream from '../helpers/ZlibHeaderTransformStream.js';
|
import ZlibHeaderTransformStream from '../helpers/ZlibHeaderTransformStream.js';
|
||||||
import callbackify from '../helpers/callbackify.js';
|
import callbackify from '../helpers/callbackify.js';
|
||||||
import shouldBypassProxy from '../helpers/shouldBypassProxy.js';
|
import shouldBypassProxy from '../helpers/shouldBypassProxy.js';
|
||||||
|
import { toByteStringHeaderObject } from '../helpers/sanitizeHeaderValue.js';
|
||||||
import {
|
import {
|
||||||
progressEventReducer,
|
progressEventReducer,
|
||||||
progressEventDecorator,
|
progressEventDecorator,
|
||||||
@@ -766,7 +767,7 @@ export default isHttpAdapterSupported &&
|
|||||||
const options = Object.assign(Object.create(null), {
|
const options = Object.assign(Object.create(null), {
|
||||||
path,
|
path,
|
||||||
method: method,
|
method: method,
|
||||||
headers: headers.toJSON(),
|
headers: toByteStringHeaderObject(headers),
|
||||||
agents: { http: config.httpAgent, https: config.httpsAgent },
|
agents: { http: config.httpAgent, https: config.httpsAgent },
|
||||||
auth,
|
auth,
|
||||||
protocol,
|
protocol,
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ import platform from '../platform/index.js';
|
|||||||
import AxiosHeaders from '../core/AxiosHeaders.js';
|
import AxiosHeaders from '../core/AxiosHeaders.js';
|
||||||
import { progressEventReducer } from '../helpers/progressEventReducer.js';
|
import { progressEventReducer } from '../helpers/progressEventReducer.js';
|
||||||
import resolveConfig from '../helpers/resolveConfig.js';
|
import resolveConfig from '../helpers/resolveConfig.js';
|
||||||
|
import { toByteStringHeaderObject } from '../helpers/sanitizeHeaderValue.js';
|
||||||
|
|
||||||
const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined';
|
const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined';
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ export default isXHRAdapterSupported &&
|
|||||||
|
|
||||||
// Add headers to the request
|
// Add headers to the request
|
||||||
if ('setRequestHeader' in 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);
|
request.setRequestHeader(key, val);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,46 +2,14 @@
|
|||||||
|
|
||||||
import utils from '../utils.js';
|
import utils from '../utils.js';
|
||||||
import parseHeaders from '../helpers/parseHeaders.js';
|
import parseHeaders from '../helpers/parseHeaders.js';
|
||||||
|
import { sanitizeHeaderValue } from '../helpers/sanitizeHeaderValue.js';
|
||||||
|
|
||||||
const $internals = Symbol('internals');
|
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) {
|
function normalizeHeader(header) {
|
||||||
return header && String(header).trim().toLowerCase();
|
return header && String(header).trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeHeaderValue(str) {
|
|
||||||
return trimSPorHTAB(str.replace(INVALID_HEADER_VALUE_CHARS_RE, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeValue(value) {
|
function normalizeValue(value) {
|
||||||
if (value === false || value == null) {
|
if (value === false || value == null) {
|
||||||
return value;
|
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);
|
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 () => {
|
it('should respect common Content-Type header', async () => {
|
||||||
const instance = axios.create();
|
const instance = axios.create();
|
||||||
instance.defaults.headers.common['Content-Type'] = 'application/custom';
|
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', () => {
|
describe('responses', () => {
|
||||||
it('should support text response type', async () => {
|
it('should support text response type', async () => {
|
||||||
const originalData = 'my data';
|
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 () => {
|
it('should parse the timeout property', async () => {
|
||||||
const server = await startHTTPServer(
|
const server = await startHTTPServer(
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
|
|||||||
@@ -118,6 +118,34 @@ describe('AxiosHeaders', () => {
|
|||||||
|
|
||||||
assert.deepStrictEqual(headers.get('set-cookie'), ['safe=1', 'unsafe=1Injected: true']);
|
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', () => {
|
it('should support uppercase name mapping for names overlapped by class methods', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user