Skip to content

Commit 2179fdb

Browse files
authored
fix(security): re-validate HttpUriPlugin redirects against allowedUris; enforce http(s) and max redirects
1 parent 512a32f commit 2179fdb

File tree

2 files changed

+81
-6
lines changed

2 files changed

+81
-6
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Re-validate HttpUriPlugin redirects against allowedUris, restrict to http(s) and add a conservative redirect limit to prevent SSRF and untrusted content inclusion. Redirects failing policy are rejected before caching/lockfile writes.

lib/schemes/HttpUriPlugin.js

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const memoize = require("../util/memoize");
3434
const getHttp = memoize(() => require("http"));
3535
const getHttps = memoize(() => require("https"));
3636

37+
const MAX_REDIRECTS = 5;
38+
3739
/**
3840
* @param {typeof import("http") | typeof import("https")} request request
3941
* @param {string | URL | undefined} proxy proxy
@@ -200,6 +202,22 @@ const areLockfileEntriesEqual = (a, b) =>
200202
const entryToString = (entry) =>
201203
`resolved: ${entry.resolved}, integrity: ${entry.integrity}, contentType: ${entry.contentType}`;
202204

205+
/**
206+
* Sanitize URL for inclusion in error messages
207+
* @param {string} href URL string to sanitize
208+
* @returns {string} sanitized URL text for logs/errors
209+
*/
210+
const sanitizeUrlForError = (href) => {
211+
try {
212+
const u = new URL(href);
213+
return `${u.protocol}//${u.host}`;
214+
} catch (_err) {
215+
return String(href)
216+
.slice(0, 200)
217+
.replace(/[\r\n]/g, "");
218+
}
219+
};
220+
203221
class Lockfile {
204222
constructor() {
205223
this.version = 1;
@@ -636,12 +654,46 @@ class HttpUriPlugin {
636654
};
637655

638656
for (const { scheme, fetch } of schemes) {
657+
/**
658+
* @param {string} location Location header value (relative or absolute)
659+
* @param {string} base current absolute URL
660+
* @returns {string} absolute, validated redirect target
661+
*/
662+
const validateRedirectLocation = (location, base) => {
663+
let nextUrl;
664+
try {
665+
nextUrl = new URL(location, base);
666+
} catch (_err) {
667+
throw new Error(
668+
`Invalid redirect URL: ${sanitizeUrlForError(location)}`
669+
);
670+
}
671+
if (nextUrl.protocol !== "http:" && nextUrl.protocol !== "https:") {
672+
throw new Error(
673+
`Redirected URL uses disallowed protocol: ${sanitizeUrlForError(nextUrl.href)}`
674+
);
675+
}
676+
if (!isAllowed(nextUrl.href)) {
677+
throw new Error(
678+
`${nextUrl.href} doesn't match the allowedUris policy after redirect. These URIs are allowed:\n${allowedUris
679+
.map((uri) => ` - ${uri}`)
680+
.join("\n")}`
681+
);
682+
}
683+
return nextUrl.href;
684+
};
639685
/**
640686
* @param {string} url URL
641687
* @param {string | null} integrity integrity
642688
* @param {(err: Error | null, resolveContentResult?: ResolveContentResult) => void} callback callback
689+
* @param {number=} redirectCount number of followed redirects
643690
*/
644-
const resolveContent = (url, integrity, callback) => {
691+
const resolveContent = (
692+
url,
693+
integrity,
694+
callback,
695+
redirectCount = 0
696+
) => {
645697
/**
646698
* @param {Error | null} err error
647699
* @param {FetchResult=} _result fetch result
@@ -653,8 +705,18 @@ class HttpUriPlugin {
653705
const result = /** @type {FetchResult} */ (_result);
654706

655707
if ("location" in result) {
708+
// Validate redirect target before following
709+
let absolute;
710+
try {
711+
absolute = validateRedirectLocation(result.location, url);
712+
} catch (err_) {
713+
return callback(/** @type {Error} */ (err_));
714+
}
715+
if (redirectCount >= MAX_REDIRECTS) {
716+
return callback(new Error("Too many redirects"));
717+
}
656718
return resolveContent(
657-
result.location,
719+
absolute,
658720
integrity,
659721
(err, innerResult) => {
660722
if (err) return callback(err);
@@ -665,7 +727,8 @@ class HttpUriPlugin {
665727
content,
666728
storeLock: storeLock && result.storeLock
667729
});
668-
}
730+
},
731+
redirectCount + 1
669732
);
670733
}
671734

@@ -781,9 +844,16 @@ class HttpUriPlugin {
781844
res.statusCode >= 301 &&
782845
res.statusCode <= 308
783846
) {
784-
const result = {
785-
location: new URL(location, url).href
786-
};
847+
let absolute;
848+
try {
849+
absolute = validateRedirectLocation(location, url);
850+
} catch (err) {
851+
logger.log(
852+
`GET ${url} [${res.statusCode}] -> ${String(location)} (rejected: ${/** @type {Error} */ (err).message})`
853+
);
854+
return callback(/** @type {Error} */ (err));
855+
}
856+
const result = { location: absolute };
787857
if (
788858
!cachedResult ||
789859
!("location" in cachedResult) ||

0 commit comments

Comments
 (0)