Skip to content

Commit cf76580

Browse files
authored
Fix denial of service from malicious indexed source maps (CVE-2026-93749) (#79)
1 parent 7899a86 commit cf76580

6 files changed

Lines changed: 219 additions & 8 deletions

‎lib/source-map-consumer.js‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ var ArraySet = require('./array-set').ArraySet;
1111
var base64VLQ = require('./base64-vlq');
1212
var quickSort = require('./quick-sort').quickSort;
1313

14+
// Upper bound for a section's `offset.line` in an indexed source map. A flat
15+
// map spends one ';' per generated line, so a tiny indexed map with a huge
16+
// offset.line turns into a huge amount of work and memory once its mappings
17+
// are copied into a SourceMapGenerator (CVE-2026-93749). Ten million lines is
18+
// far beyond any real generated file. Columns are VLQ-encoded as deltas and
19+
// don't amplify, so they only need to be valid integers.
20+
var MAX_SECTION_OFFSET_LINE = 1e7;
21+
1422
function SourceMapConsumer(aSourceMap, aSourceMapURL) {
1523
var sourceMap = aSourceMap;
1624
if (typeof aSourceMap === 'string') {
@@ -935,6 +943,7 @@ function IndexedSourceMapConsumer(aSourceMap, aSourceMapURL) {
935943
line: -1,
936944
column: 0
937945
};
946+
var maxOffsetLine = 0;
938947
this._sections = sections.map(function (s) {
939948
if (s.url) {
940949
// The url field will require support for asynchronicity.
@@ -945,22 +954,57 @@ function IndexedSourceMapConsumer(aSourceMap, aSourceMapURL) {
945954
var offsetLine = util.getArg(offset, 'line');
946955
var offsetColumn = util.getArg(offset, 'column');
947956

957+
if (!isValidOffset(offsetLine) || !isValidOffset(offsetColumn)) {
958+
throw new Error('Section offset line and column must be non-negative integers.');
959+
}
960+
if (offsetLine > MAX_SECTION_OFFSET_LINE) {
961+
throw new Error('Section offset line must not exceed ' + MAX_SECTION_OFFSET_LINE + '.');
962+
}
963+
948964
if (offsetLine < lastOffset.line ||
949965
(offsetLine === lastOffset.line && offsetColumn < lastOffset.column)) {
950966
throw new Error('Section offsets must be ordered and non-overlapping.');
951967
}
952968
lastOffset = offset;
953969

970+
var consumer = new SourceMapConsumer(util.getArg(s, 'map'), aSourceMapURL);
971+
972+
// Offsets of a nested indexed map add up with this one, so apply the
973+
// bound to the total.
974+
var totalOffsetLine = offsetLine + (consumer._maxOffsetLine || 0);
975+
if (totalOffsetLine > MAX_SECTION_OFFSET_LINE) {
976+
throw new Error('Section offset line must not exceed ' + MAX_SECTION_OFFSET_LINE +
977+
', including offsets of nested sections.');
978+
}
979+
if (totalOffsetLine > maxOffsetLine) {
980+
maxOffsetLine = totalOffsetLine;
981+
}
982+
954983
return {
955984
generatedOffset: {
956985
// The offset fields are 0-based, but we use 1-based indices when
957986
// encoding/decoding from VLQ.
958987
generatedLine: offsetLine + 1,
959988
generatedColumn: offsetColumn + 1
960989
},
961-
consumer: new SourceMapConsumer(util.getArg(s, 'map'), aSourceMapURL)
990+
consumer: consumer
962991
}
963992
});
993+
994+
// The largest offset.line of any section, including nested sections.
995+
this._maxOffsetLine = maxOffsetLine;
996+
}
997+
998+
/**
999+
* Section offsets come from untrusted input and are used as line/column
1000+
* numbers, so only accept non-negative safe integers (rejects NaN, Infinity,
1001+
* strings, fractions, etc).
1002+
*/
1003+
function isValidOffset(aValue) {
1004+
return typeof aValue === 'number' &&
1005+
aValue >= 0 &&
1006+
aValue <= 9007199254740991 && // Number.MAX_SAFE_INTEGER
1007+
Math.floor(aValue) === aValue;
9641008
}
9651009

9661010
IndexedSourceMapConsumer.prototype = Object.create(SourceMapConsumer.prototype);
@@ -978,8 +1022,12 @@ Object.defineProperty(IndexedSourceMapConsumer.prototype, 'sources', {
9781022
get: function () {
9791023
var sources = [];
9801024
for (var i = 0; i < this._sections.length; i++) {
981-
for (var j = 0; j < this._sections[i].consumer.sources.length; j++) {
982-
sources.push(this._sections[i].consumer.sources[j]);
1025+
// Read the getter once per section: for a nested indexed map it is
1026+
// itself this getter, and re-reading it per item is exponential in the
1027+
// nesting depth (CVE-2026-93749).
1028+
var sectionSources = this._sections[i].consumer.sources;
1029+
for (var j = 0; j < sectionSources.length; j++) {
1030+
sources.push(sectionSources[j]);
9831031
}
9841032
}
9851033
return sources;

‎lib/source-map-generator.js‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,12 @@ SourceMapGenerator.prototype._serializeMappings =
348348

349349
if (mapping.generatedLine !== previousGeneratedLine) {
350350
previousGeneratedColumn = 0;
351-
while (mapping.generatedLine !== previousGeneratedLine) {
352-
next += ';';
353-
previousGeneratedLine++;
354-
}
351+
// One ';' per skipped line. Build large gaps with a single flat
352+
// string: appending ';' one by one creates a rope node per line, which
353+
// can exhaust the heap for a big gap (CVE-2026-93749).
354+
var lineDelta = mapping.generatedLine - previousGeneratedLine;
355+
next += lineDelta === 1 ? ';' : ';'.repeat(lineDelta);
356+
previousGeneratedLine = mapping.generatedLine;
355357
}
356358
else {
357359
if (i > 0) {

‎lib/source-node.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ SourceNode.fromStringWithSourceMap =
113113
// to the SourceNode without any mapping.
114114
// Each line is added as separate string.
115115
while (lastGeneratedLine < mapping.generatedLine) {
116+
if (remainingLinesIndex >= remainingLines.length) {
117+
// The generated code is exhausted. Skip the rest instead of adding
118+
// empty lines one by one: a mapping may point far past the end of
119+
// the code (e.g. an indexed map section with a huge offset.line).
120+
lastGeneratedLine = mapping.generatedLine;
121+
break;
122+
}
116123
node.add(shiftNextLine());
117124
lastGeneratedLine++;
118125
}

‎test/test-source-map-consumer.js‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,87 @@ exports['test indexed source map errors when sections are out of order by line']
895895
}, Error);
896896
};
897897

898+
exports['test indexed source map errors on invalid section offsets'] = function(assert) {
899+
var invalid = [-1, 1.5, NaN, Infinity, -Infinity, 1e999, 9007199254740992, '1', null, undefined, {}];
900+
901+
invalid.forEach(function (value) {
902+
['line', 'column'].forEach(function (field) {
903+
var map = JSON.parse(JSON.stringify(util.indexedTestMap));
904+
map.sections[0].offset[field] = value;
905+
906+
assert.throws(function() {
907+
new SourceMapConsumer(map);
908+
}, Error, 'offset.' + field + ' = ' + String(value));
909+
});
910+
});
911+
};
912+
913+
exports['test indexed source map errors when section offset line is too large'] = function(assert) {
914+
var map = JSON.parse(JSON.stringify(util.indexedTestMap));
915+
map.sections[1].offset = { line: 1e7 + 1, column: 0 };
916+
917+
assert.throws(function() {
918+
new SourceMapConsumer(map);
919+
}, /must not exceed/);
920+
};
921+
922+
exports['test indexed source map accepts large valid section offsets'] = function(assert) {
923+
var map = JSON.parse(JSON.stringify(util.indexedTestMap));
924+
map.sections[0].offset = { line: 0, column: 5000000 };
925+
map.sections[1].offset = { line: 1e7, column: 0 };
926+
927+
var consumer = new SourceMapConsumer(map);
928+
var pos = consumer.originalPositionFor({ line: 1e7 + 1, column: 1 });
929+
assert.equal(pos.source, '/the/root/two.js');
930+
pos = consumer.originalPositionFor({ line: 1, column: 5000001 });
931+
assert.equal(pos.source, '/the/root/one.js');
932+
};
933+
934+
function nestedIndexedMap(depth, offsetLine) {
935+
var map = { version: 3, sources: ['a.js'], sourcesContent: ['a'], names: [], mappings: 'AAAA' };
936+
for (var i = 0; i < depth; i++) {
937+
map = { version: 3, sections: [{ offset: { line: offsetLine, column: 0 }, map: map }] };
938+
}
939+
return map;
940+
}
941+
942+
exports['test nested indexed source map offsets add up'] = function(assert) {
943+
var consumer = new SourceMapConsumer(nestedIndexedMap(5, 1000));
944+
var lines = [];
945+
consumer.eachMapping(function (m) { lines.push(m.generatedLine); });
946+
assert.deepEqual(lines, [5001]);
947+
};
948+
949+
exports['test nested indexed source map errors when total offset line is too large'] = function(assert) {
950+
// Each level is within the bound, but together they exceed it.
951+
new SourceMapConsumer(nestedIndexedMap(2, 5e6));
952+
assert.throws(function() {
953+
new SourceMapConsumer(nestedIndexedMap(3, 5e6));
954+
}, /including offsets of nested sections/);
955+
};
956+
957+
exports['test nested indexed source map sources is linear in depth'] = function(assert) {
958+
// CVE-2026-93749: the sources getter used to re-read each nested
959+
// consumer's sources per item, which is exponential in the nesting depth.
960+
var depth = 12;
961+
var consumer = new SourceMapConsumer(nestedIndexedMap(depth, 1));
962+
var innermost = consumer;
963+
for (var i = 0; i < depth; i++) {
964+
innermost = innermost._sections[0].consumer;
965+
}
966+
var reads = 0;
967+
var innermostSources = innermost.sources;
968+
Object.defineProperty(innermost, 'sources', {
969+
get: function () {
970+
reads++;
971+
return innermostSources;
972+
}
973+
});
974+
975+
assert.deepEqual(consumer.sources, ['a.js']);
976+
assert.equal(reads, 1);
977+
};
978+
898979
exports['test github issue #64'] = function (assert) {
899980
var map = new SourceMapConsumer({
900981
"version": 3,

‎test/test-source-map-generator.js‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,4 +782,40 @@ exports['test that we can create a generator from a source map with empty mappin
782782
const fromSourceMap = fromConsumer.toJSON();
783783
assert.ok(fromSourceMap, "Creating a generator from a map with only generated position did not throw");
784784
assert.equal(fromSourceMap.mappings, originalMap.mappings);
785-
}
785+
}
786+
exports['test serializing large generated line gaps'] = function (assert) {
787+
var map = new SourceMapGenerator({ file: 'min.js' });
788+
map.addMapping({ generated: { line: 1, column: 0 } });
789+
map.addMapping({ generated: { line: 3, column: 0 } });
790+
map.addMapping({ generated: { line: 1e6 + 3, column: 0 } });
791+
792+
var mappings = map.toJSON().mappings;
793+
assert.equal(mappings.length, 3 + 2 + 1e6);
794+
assert.equal(mappings.slice(0, 4), 'A;;A');
795+
assert.equal(mappings.slice(-2), ';A');
796+
assert.equal(mappings.indexOf('A', 4), mappings.length - 1);
797+
};
798+
799+
exports['test copying an indexed map with a large section offset into a generator'] = function (assert) {
800+
// CVE-2026-93749: copying mappings from an indexed map with a large
801+
// section offset.line must not build the ';' run one rope node at a time.
802+
var consumer = new SourceMapConsumer({
803+
version: 3,
804+
sections: [{
805+
offset: { line: 1e7, column: 0 },
806+
map: { version: 3, sources: ['a.js'], names: [], mappings: 'AAAA' }
807+
}]
808+
});
809+
var map = new SourceMapGenerator({ file: 'min.js' });
810+
consumer.eachMapping(function (m) {
811+
map.addMapping({
812+
generated: { line: m.generatedLine, column: m.generatedColumn },
813+
original: { line: m.originalLine, column: m.originalColumn },
814+
source: m.source
815+
});
816+
});
817+
818+
var mappings = map.toJSON().mappings;
819+
assert.equal(mappings.length, 1e7 + 4);
820+
assert.equal(mappings.slice(-5), ';AAAA');
821+
};

‎test/test-source-node.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,43 @@ exports['test .fromStringWithSourceMap() complex version'] = forEachNewline(func
274274
util.assertEqualMaps(assert, map, inputMap);
275275
});
276276

277+
exports['test .fromStringWithSourceMap() with indexed map offset past end of code'] = function (assert) {
278+
// CVE-2026-93749: a section offset.line far past the end of the code must
279+
// not make fromStringWithSourceMap add empty lines one at a time.
280+
var map = {
281+
version: 3,
282+
sections: [{
283+
offset: { line: 1e7, column: 0 },
284+
map: {
285+
version: 3,
286+
sources: ['a.js'],
287+
sourcesContent: ['a'],
288+
names: [],
289+
mappings: 'AAAA'
290+
}
291+
}]
292+
};
293+
294+
var node = SourceNode.fromStringWithSourceMap('var x;\n', new SourceMapConsumer(map));
295+
assert.equal(node.toString(), 'var x;\n');
296+
assert.ok(node.children.length < 10, node.children.length + ' children');
297+
};
298+
299+
exports['test .fromStringWithSourceMap() with deeply nested indexed map'] = function (assert) {
300+
// CVE-2026-93749: nested indexed maps used to make the sources lookup in
301+
// fromStringWithSourceMap exponential in the nesting depth.
302+
var map = { version: 3, sources: ['a.js'], sourcesContent: ['a'], names: [], mappings: 'AAAA' };
303+
for (var i = 0; i < 40; i++) {
304+
map = { version: 3, sections: [{ offset: { line: 0, column: 0 }, map: map }] };
305+
}
306+
307+
var node = SourceNode.fromStringWithSourceMap('var x;\n', new SourceMapConsumer(map));
308+
assert.equal(node.toString(), 'var x;\n');
309+
var contents = {};
310+
node.walkSourceContents(function (source, content) { contents[source] = content; });
311+
assert.deepEqual(contents, { 'a.js': 'a' });
312+
};
313+
277314
exports['test .fromStringWithSourceMap() third argument'] = function (assert) {
278315
// Assume the following directory structure:
279316
//

0 commit comments

Comments
 (0)