From 5bdb155309489224d3bac88e4c4548327a581ff8 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 26 Sep 2026 13:00:16 +0800 Subject: [PATCH 1/2] fix: bound synthesized rows in OpenXml queries --- .../OpenXml/ExcelOpenXmlSheetReader.cs | 50 +++++++++--- src/MiniExcel/OpenXml/OpenXmlConfiguration.cs | 6 ++ src/MiniExcel/Utils/ReferenceHelper.cs | 7 +- .../MiniExcelIssueAsyncTests.cs | 19 +++++ tests/MiniExcelTests/MiniExcelIssueTests.cs | 80 +++++++++++++++++++ tests/MiniExcelTests/MiniExcelOpenXmlTests.cs | 2 + tests/MiniExcelTests/Utils/Helpers.cs | 15 ++++ 7 files changed, 168 insertions(+), 11 deletions(-) diff --git a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs index bd75ef48..2cb3e150 100644 --- a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs +++ b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs @@ -84,13 +84,13 @@ public IEnumerable> QueryRange(bool useHeaderRow, st public IEnumerable> QueryRange(bool useHeaderRow, string sheetName, int startRowIndex, int startColumnIndex, int? endRowIndex, int? endColumnIndex) { - if (startRowIndex <= 0) + if (startRowIndex is <= 0 or > ReferenceHelper.MaxRowNumber) { - throw new ArgumentOutOfRangeException(nameof(startRowIndex), "Start row index is 1-based and must be greater than 0."); + throw new ArgumentOutOfRangeException(nameof(startRowIndex), $"Start row index must be between 1 and {ReferenceHelper.MaxRowNumber}."); } - if (startColumnIndex <= 0) + if (startColumnIndex is <= 0 or > ReferenceHelper.MaxColumnNumber) { - throw new ArgumentOutOfRangeException(nameof(startColumnIndex), "Start column index is 1-based and must be greater than 0."); + throw new ArgumentOutOfRangeException(nameof(startColumnIndex), $"Start column index must be between 1 and {ReferenceHelper.MaxColumnNumber}."); } // convert to 0-based startColumnIndex--; @@ -98,18 +98,18 @@ public IEnumerable> QueryRange(bool useHeaderRow, st if (endRowIndex.HasValue) { - if (endRowIndex.Value <= 0) + if (endRowIndex.Value is <= 0 or > ReferenceHelper.MaxRowNumber) { - throw new ArgumentOutOfRangeException(nameof(endRowIndex), "End row index is 1-based and must be greater than 0."); + throw new ArgumentOutOfRangeException(nameof(endRowIndex), $"End row index must be between 1 and {ReferenceHelper.MaxRowNumber}."); } // convert to 0-based endRowIndex--; } if (endColumnIndex.HasValue) { - if (endColumnIndex.Value <= 0) + if (endColumnIndex.Value is <= 0 or > ReferenceHelper.MaxColumnNumber) { - throw new ArgumentOutOfRangeException(nameof(endColumnIndex), "End column index is 1-based and must be greater than 0."); + throw new ArgumentOutOfRangeException(nameof(endColumnIndex), $"End column index must be between 1 and {ReferenceHelper.MaxColumnNumber}."); } // convert to 0-based endColumnIndex--; @@ -164,15 +164,29 @@ internal IEnumerable> InternalQueryRange(bool useHea var headRows = new Dictionary(); int rowIndex = -1; bool isFirstRow = true; + long synthesizedCellCount = 0; while (!reader.EOF) { if (XmlReaderHelper.IsStartElement(reader, "row", _ns)) { var nextRowIndex = rowIndex + 1; - if (int.TryParse(reader.GetAttribute("r"), out int arValue)) - rowIndex = arValue - 1; // The row attribute is 1-based + var rowAttribute = reader.GetAttribute("r"); + if (rowAttribute != null) + { + if (!int.TryParse(rowAttribute, NumberStyles.None, CultureInfo.InvariantCulture, out var rowNumber) + || rowNumber is < 1 or > ReferenceHelper.MaxRowNumber) + { + throw new InvalidDataException($"Row index '{rowAttribute}' is outside Excel's valid range."); + } + + rowIndex = rowNumber - 1; // The row attribute is 1-based + } else + { rowIndex++; + if (rowIndex >= ReferenceHelper.MaxRowNumber) + throw new InvalidDataException("The worksheet contains more rows than Excel supports."); + } if (rowIndex < startRowIndex) { @@ -188,6 +202,22 @@ internal IEnumerable> InternalQueryRange(bool useHea break; } + if (!_config.IgnoreEmptyRows && _config.MaxSynthesizedCells.HasValue) + { + var expectedRowIndex = isFirstRow ? startRowIndex : nextRowIndex; + var emptyRowCount = Math.Max(0, rowIndex - expectedRowIndex); + var columnCount = useHeaderRow + ? Math.Max(1, headRows.Count) + : Math.Max(1, maxColumnIndex - startColumnIndex + 1); + synthesizedCellCount += (long)emptyRowCount * columnCount; + + if (synthesizedCellCount > _config.MaxSynthesizedCells.Value) + { + throw new InvalidDataException( + $"The worksheet exceeds the configured limit of {_config.MaxSynthesizedCells.Value} synthesized empty cells."); + } + } + foreach (var row in QueryRow(reader, isFirstRow, startRowIndex, nextRowIndex, rowIndex, startColumnIndex, endColumnIndex, maxColumnIndex, withoutCR, useHeaderRow, headRows, _mergeCells)) { if (isFirstRow) diff --git a/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs b/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs index 3ae71412..1b4563af 100644 --- a/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs +++ b/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs @@ -17,6 +17,12 @@ public class OpenXmlConfiguration : Configuration public bool WriteEmptyStringAsNull { get; set; } = false; public bool TrimColumnNames { get; set; } = true; public bool IgnoreEmptyRows { get; set; } = false; + + /// + /// Maximum number of cells that may be synthesized for implicit empty rows during a query. + /// Each synthesized row counts as at least one cell. Set to to disable the limit. + /// + public long? MaxSynthesizedCells { get; set; } = 100_000; public bool EnableSharedStringCache { get; set; } = true; public long SharedStringCacheSize { get; set; } = 5 * 1024 * 1024; diff --git a/src/MiniExcel/Utils/ReferenceHelper.cs b/src/MiniExcel/Utils/ReferenceHelper.cs index a88e2689..48fdebc3 100644 --- a/src/MiniExcel/Utils/ReferenceHelper.cs +++ b/src/MiniExcel/Utils/ReferenceHelper.cs @@ -2,6 +2,9 @@ internal static class ReferenceHelper { + public const int MaxColumnNumber = 16_384; + public const int MaxRowNumber = 1_048_576; + public static string GetCellNumber(string cell) { return cell @@ -70,6 +73,8 @@ public static bool ParseReference(string value, out int column, out int row) position++; column *= 26; column += c - offset; + if (column > MaxColumnNumber) + return false; continue; } @@ -105,6 +110,6 @@ public static bool ParseReference(string value, out int column, out int row) row = row * 10 + digit; } - return row > 0; + return row is > 0 and <= MaxRowNumber; } } \ No newline at end of file diff --git a/tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs b/tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs index a8f19a57..3f81ca0f 100644 --- a/tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs +++ b/tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs @@ -18,6 +18,25 @@ public class MiniExcelIssueAsyncTests(ITestOutputHelper output) { private readonly ITestOutputHelper _output = output; + [Fact] + public async Task QueryAsyncRejectsRowIndexOutsideWorksheetLimits() + { + using var path = AutoDeletingPath.Create(); + MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } }); + Helpers.ReplaceFirstSheetXml(path.ToString(), """ + + + + + x + + + """); + + var rows = await MiniExcel.QueryAsync(path.ToString()); + Assert.Throws(() => rows.First()); + } + [Fact] public async Task Issue997() { diff --git a/tests/MiniExcelTests/MiniExcelIssueTests.cs b/tests/MiniExcelTests/MiniExcelIssueTests.cs index baf21e65..1ee11af5 100644 --- a/tests/MiniExcelTests/MiniExcelIssueTests.cs +++ b/tests/MiniExcelTests/MiniExcelIssueTests.cs @@ -29,6 +29,86 @@ public class MiniExcelIssueTests(ITestOutputHelper output) { private readonly ITestOutputHelper _output = output; + [Theory] + [InlineData("1048577")] + [InlineData("2000000000")] + public void QueryRejectsRowIndexOutsideWorksheetLimits(string rowNumber) + { + using var path = AutoDeletingPath.Create(); + MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } }); + Helpers.ReplaceFirstSheetXml(path.ToString(), $""" + + + + + x + + + """); + + Assert.Throws(() => MiniExcel.Query(path.ToString()).First()); + } + + [Fact] + public void QueryRejectsDimensionOutsideWorksheetLimits() + { + using var path = AutoDeletingPath.Create(); + MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } }); + Helpers.ReplaceFirstSheetXml(path.ToString(), """ + + + + x + + """); + + Assert.Throws(() => MiniExcel.Query(path.ToString()).First()); + } + + [Fact] + public void QueryRangeAcceptsMaximumWorksheetCoordinates() + { + using var path = AutoDeletingPath.Create(); + MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } }); + Helpers.ReplaceFirstSheetXml(path.ToString(), """ + + + + + x + + + """); + + var row = MiniExcel.QueryRange( + path.ToString(), + startRowIndex: ReferenceHelper.MaxRowNumber, + startColumnIndex: ReferenceHelper.MaxColumnNumber, + endRowIndex: ReferenceHelper.MaxRowNumber, + endColumnIndex: ReferenceHelper.MaxColumnNumber).Single(); + + Assert.Equal("x", row.XFD); + } + + [Fact] + public void QueryRejectsWorksheetExceedingSynthesizedCellLimit() + { + using var path = AutoDeletingPath.Create(); + MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } }); + Helpers.ReplaceFirstSheetXml(path.ToString(), """ + + + + + x + + + """); + + var exception = Assert.Throws(() => MiniExcel.Query(path.ToString()).First()); + Assert.Contains("synthesized empty cells", exception.Message); + } + /// /// https://github.com/mini-software/MiniExcel/issues/549 /// diff --git a/tests/MiniExcelTests/MiniExcelOpenXmlTests.cs b/tests/MiniExcelTests/MiniExcelOpenXmlTests.cs index 47a9c75e..55844bad 100644 --- a/tests/MiniExcelTests/MiniExcelOpenXmlTests.cs +++ b/tests/MiniExcelTests/MiniExcelOpenXmlTests.cs @@ -166,6 +166,8 @@ public void ParseValidCellReference(string reference, int expectedColumn, int ex [InlineData("A0")] [InlineData("A1x")] [InlineData("A2147483648")] + [InlineData("A1048577")] + [InlineData("XFE1")] public void RejectInvalidCellReference(string reference) { Assert.False(MiniExcelLibs.Utils.ReferenceHelper.ParseReference(reference, out _, out _)); diff --git a/tests/MiniExcelTests/Utils/Helpers.cs b/tests/MiniExcelTests/Utils/Helpers.cs index 40695ac7..a183493d 100644 --- a/tests/MiniExcelTests/Utils/Helpers.cs +++ b/tests/MiniExcelTests/Utils/Helpers.cs @@ -76,6 +76,21 @@ internal static string GetZipFileContent(string zipPath, string filePath) return doc.ToString(); } + internal static void ReplaceFirstSheetXml(string path, string worksheetXml) + { + using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); + using var archive = new ZipArchive(stream, ZipArchiveMode.Update, false, Encoding.UTF8); + var sheet = archive.Entries.Single(w => + w.FullName.StartsWith("xl/worksheets/sheet1", StringComparison.OrdinalIgnoreCase) || + w.FullName.StartsWith("/xl/worksheets/sheet1", StringComparison.OrdinalIgnoreCase)); + var entryName = sheet.FullName; + sheet.Delete(); + + var replacement = archive.CreateEntry(entryName); + using var writer = new StreamWriter(replacement.Open(), new UTF8Encoding(false)); + writer.Write(worksheetXml); + } + internal static string? GetFirstSheetDimensionRefValue(string path) { var ns = new XmlNamespaceManager(new NameTable()); From dc9b90a3f24307c7266b04d83e119e962fba8619 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 26 Sep 2026 20:00:47 +0200 Subject: [PATCH 2/2] Copied adjustments from #1016 - Added check to `ReadCellAndSetColumnIndexAsync` to throw an exception when the column number exceeds the Excel column limit - Included empty row tags in the calculation of synthesized cells count --- src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs index 2cb3e150..8ca80b4a 100644 --- a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs +++ b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs @@ -209,7 +209,9 @@ internal IEnumerable> InternalQueryRange(bool useHea var columnCount = useHeaderRow ? Math.Max(1, headRows.Count) : Math.Max(1, maxColumnIndex - startColumnIndex + 1); - synthesizedCellCount += (long)emptyRowCount * columnCount; + + var selfClosingRowCount = reader.IsEmptyElement ? 1 : 0; + synthesizedCellCount += (long)(emptyRowCount + selfClosingRowCount) * columnCount; if (synthesizedCellCount > _config.MaxSynthesizedCells.Value) { @@ -673,6 +675,8 @@ private CellAndColumn ReadCellAndSetColumnIndex(XmlReader reader, int columnInde //TODO:need to check only need nextColumnIndex or columnIndex else if (ReferenceHelper.ParseReference(aR, out int referenceColumn, out _)) newColumnIndex = referenceColumn - 1; // ParseReference is 1-based + else if (!string.IsNullOrEmpty(aR) && referenceColumn > ReferenceHelper.MaxColumnNumber) + throw new InvalidDataException($"Cell reference '{aR}' is invalid."); else newColumnIndex = columnIndex;