From b0be1b0c213225950c1a5db5ba67eb9bb9facc1e Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 26 Sep 2026 12:41:48 +0800 Subject: [PATCH 1/3] fix: bound synthesized rows in OpenXml queries --- .../Helpers/CellReferenceConverter.cs | 9 +- src/MiniExcel.OpenXml/OpenXmlConfiguration.cs | 6 ++ src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 50 ++++++++-- .../Issues/MiniExcelGithubIssuesAsyncTests.cs | 19 ++++ .../Issues/MiniExcelGithubIssuesTests.cs | 91 +++++++++++++++++++ .../Utils/SheetHelper.cs | 15 +++ 6 files changed, 178 insertions(+), 12 deletions(-) diff --git a/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs b/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs index 9a7129f2..2f2717be 100644 --- a/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs +++ b/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs @@ -5,7 +5,9 @@ namespace MiniExcelLib.Core.Helpers; public static class CellReferenceConverter { private const int GeneralColumnIndex = 255; - private const int MaxColumnIndex = 16383; + public const int MaxColumnNumber = 16_384; + public const int MaxRowNumber = 1_048_576; + private const int MaxColumnIndex = MaxColumnNumber - 1; private static readonly ConcurrentDictionary IntMappingToAlphabet = new(); private static readonly ConcurrentDictionary AlphabetMappingToInt = new(); @@ -92,6 +94,8 @@ public static bool TryParseCellReference(string? value, out int column, out int { position++; column = column * 26 + c - offset; + if (column > MaxColumnNumber) + return false; continue; } @@ -112,6 +116,7 @@ public static bool TryParseCellReference(string? value, out int column, out int if (position == 0) return false; - return int.TryParse(value[position..], NumberStyles.None, CultureInfo.InvariantCulture, out row) && row > 0; + return int.TryParse(value[position..], NumberStyles.None, CultureInfo.InvariantCulture, out row) + && row is > 0 and <= MaxRowNumber; } } diff --git a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs index b6f15f4d..a5007bba 100644 --- a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs +++ b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs @@ -19,6 +19,12 @@ public class OpenXmlConfiguration : MiniExcelBaseConfiguration 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 StringStorageMode StringStorageMode { get; set; } = StringStorageMode.Inline; public bool EnableSharedStringCache { get; set; } = true; diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index 1f9aa619..209cc80d 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -126,10 +126,10 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC { cancellationToken.ThrowIfCancellationRequested(); - if (startRowIndex <= 0) - throw new ArgumentOutOfRangeException(nameof(startRowIndex), "Start row index is 1-based and must be greater than 0."); - if (startColumnIndex <= 0) - throw new ArgumentOutOfRangeException(nameof(startColumnIndex), "Start column index is 1-based and must be greater than 0."); + if (startRowIndex is <= 0 or > CellReferenceConverter.MaxRowNumber) + throw new ArgumentOutOfRangeException(nameof(startRowIndex), $"Start row index must be between 1 and {CellReferenceConverter.MaxRowNumber}."); + if (startColumnIndex is <= 0 or > CellReferenceConverter.MaxColumnNumber) + throw new ArgumentOutOfRangeException(nameof(startColumnIndex), $"Start column index must be between 1 and {CellReferenceConverter.MaxColumnNumber}."); // convert to 0-based startColumnIndex--; @@ -137,22 +137,22 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC if (endRowIndex.HasValue) { - if (endRowIndex.Value <= 0) - throw new ArgumentOutOfRangeException(nameof(endRowIndex), "End row index is 1-based and must be greater than 0."); + if (endRowIndex.Value is <= 0 or > CellReferenceConverter.MaxRowNumber) + throw new ArgumentOutOfRangeException(nameof(endRowIndex), $"End row index must be between 1 and {CellReferenceConverter.MaxRowNumber}."); // convert to 0-based endRowIndex--; } if (endColumnIndex.HasValue) { - if (endColumnIndex.Value > 0) + if (endColumnIndex.Value is > 0 and <= CellReferenceConverter.MaxColumnNumber) { // convert to 0-based endColumnIndex--; } else { - 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 {CellReferenceConverter.MaxColumnNumber}."); } } @@ -222,16 +222,30 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC int rowIndex = -1; bool isFirstRow = true; + long synthesizedCellCount = 0; var headRows = new Dictionary(); while (!reader.EOF) { if (reader.IsStartElement("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 is not null) + { + if (!int.TryParse(rowAttribute, NumberStyles.None, CultureInfo.InvariantCulture, out var rowNumber) + || rowNumber is < 1 or > CellReferenceConverter.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 >= CellReferenceConverter.MaxRowNumber) + throw new InvalidDataException("The worksheet contains more rows than Excel supports."); + } if (rowIndex < startRowIndex) { @@ -247,6 +261,22 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC break; } + if (!_config.IgnoreEmptyRows && _config.MaxSynthesizedCells is { } maxSynthesizedCells) + { + var expectedRowIndex = isFirstRow ? startRowIndex : nextRowIndex; + var emptyRowCount = Math.Max(0, rowIndex - expectedRowIndex); + var columnCount = hasHeaderRow + ? Math.Max(1, headRows.Count) + : Math.Max(1, maxColumnIndex - startColumnIndex + 1); + synthesizedCellCount += (long)emptyRowCount * columnCount; + + if (synthesizedCellCount > maxSynthesizedCells) + { + throw new InvalidDataException( + $"The worksheet exceeds the configured limit of {maxSynthesizedCells} synthesized empty cells."); + } + } + var query = QueryRowAsync(reader, isFirstRow, startRowIndex, nextRowIndex, rowIndex, startColumnIndex, endColumnIndex, maxColumnIndex, withoutCr, hasHeaderRow, headRows, mergeCells, cancellationToken); diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs index bf85ff61..ddf9611f 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs @@ -17,6 +17,25 @@ static MiniExcelGithubIssuesAsyncTests() ExcelPackage.LicenseContext = LicenseContext.NonCommercial; } + [Fact] + public async Task QueryAsyncRejectsRowIndexOutsideWorksheetLimits() + { + using var path = AutoDeletingPath.Create(); + await _excelExporter.ExportAsync(path.FilePath, new[] { new { Value = "control" } }); + SheetHelper.ReplaceFirstSheetXml(path.FilePath, """ + + + + + x + + + """); + + await Assert.ThrowsAsync(async () => + await _excelImporter.QueryAsync(path.FilePath).FirstAsync()); + } + [Fact] public async Task EmptyDataReaderIssue() { diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs index 56b4d853..b5b1090a 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using MiniExcelLib.Core.Enums; using MiniExcelLib.Core.Exceptions; +using MiniExcelLib.Core.Helpers; using MiniExcelLib.OpenXml.Picture; using MiniExcelLib.OpenXml.Tests.Utils; using MiniExcelLib.Tests.Common.Utils; @@ -24,6 +25,96 @@ static MiniExcelGithubIssuesTests() private static bool IsDateFormatString(string formatCode) => DateTimeHelper.IsDateTimeFormat(formatCode); + [Fact] + public void CellReferenceConverterEnforcesWorksheetLimits() + { + Assert.True(CellReferenceConverter.TryParseCellReference("XFD1048576", out var column, out var row)); + Assert.Equal(CellReferenceConverter.MaxColumnNumber, column); + Assert.Equal(CellReferenceConverter.MaxRowNumber, row); + Assert.False(CellReferenceConverter.TryParseCellReference("XFE1", out _, out _)); + Assert.False(CellReferenceConverter.TryParseCellReference("A1048577", out _, out _)); + } + + [Fact] + public void QueryRangeAcceptsMaximumWorksheetCoordinates() + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Value = "control" } }); + SheetHelper.ReplaceFirstSheetXml(path.FilePath, """ + + + + + x + + + """); + + var row = _excelImporter.QueryRange( + path.FilePath, + startRowIndex: CellReferenceConverter.MaxRowNumber, + startColumnIndex: CellReferenceConverter.MaxColumnNumber, + endRowIndex: CellReferenceConverter.MaxRowNumber, + endColumnIndex: CellReferenceConverter.MaxColumnNumber).Single(); + + Assert.Equal("x", row.XFD); + } + + [Fact] + public void QueryRejectsWorksheetExceedingSynthesizedCellLimit() + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Value = "control" } }); + SheetHelper.ReplaceFirstSheetXml(path.FilePath, """ + + + + + x + + + """); + + var exception = Assert.Throws(() => _excelImporter.Query(path.FilePath).First()); + Assert.Contains("synthesized empty cells", exception.Message); + } + + [Theory] + [InlineData("1048577")] + [InlineData("2000000000")] + public void QueryRejectsRowIndexOutsideWorksheetLimits(string rowNumber) + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Value = "control" } }); + SheetHelper.ReplaceFirstSheetXml(path.FilePath, $""" + + + + + x + + + """); + + Assert.Throws(() => _excelImporter.Query(path.FilePath).First()); + } + + [Fact] + public void QueryRejectsDimensionOutsideWorksheetLimits() + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Value = "control" } }); + SheetHelper.ReplaceFirstSheetXml(path.FilePath, """ + + + + x + + """); + + Assert.Throws(() => _excelImporter.Query(path.FilePath).First()); + } + [Fact] public void TestIssue_DataReaderSupportDimension() { diff --git a/tests/MiniExcel.OpenXml.Tests/Utils/SheetHelper.cs b/tests/MiniExcel.OpenXml.Tests/Utils/SheetHelper.cs index 26cc2961..43780704 100644 --- a/tests/MiniExcel.OpenXml.Tests/Utils/SheetHelper.cs +++ b/tests/MiniExcel.OpenXml.Tests/Utils/SheetHelper.cs @@ -73,6 +73,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 5365a2bd4bb23a244f6beb3e0c93a31fa16ddc34 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 26 Sep 2026 18:21:02 +0200 Subject: [PATCH 2/3] Minor adjustments - Added check to `ReadCellAndSetColumnIndexAsync` to throw an exception when the column number exceeds the Excel column limit - Made a few small formatting changes for readability purposes --- .../Helpers/CellReferenceConverter.cs | 3 +- src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 33 +++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs b/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs index 2f2717be..7033f754 100644 --- a/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs +++ b/src/MiniExcel.Core/Helpers/CellReferenceConverter.cs @@ -7,7 +7,6 @@ public static class CellReferenceConverter private const int GeneralColumnIndex = 255; public const int MaxColumnNumber = 16_384; public const int MaxRowNumber = 1_048_576; - private const int MaxColumnIndex = MaxColumnNumber - 1; private static readonly ConcurrentDictionary IntMappingToAlphabet = new(); private static readonly ConcurrentDictionary AlphabetMappingToInt = new(); @@ -37,7 +36,7 @@ private static void EnsureMappingsUpTo(int columnIndex) if (columnIndex < IntMappingToAlphabet.Count) return; - if (columnIndex > MaxColumnIndex) + if (columnIndex > MaxColumnNumber - 1) throw new InvalidDataException($"Column index {columnIndex} exceeds Excel's maximum valid index."); for (int i = IntMappingToAlphabet.Count; i <= columnIndex; i++) diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index 209cc80d..a146f96c 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -128,6 +128,7 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC if (startRowIndex is <= 0 or > CellReferenceConverter.MaxRowNumber) throw new ArgumentOutOfRangeException(nameof(startRowIndex), $"Start row index must be between 1 and {CellReferenceConverter.MaxRowNumber}."); + if (startColumnIndex is <= 0 or > CellReferenceConverter.MaxColumnNumber) throw new ArgumentOutOfRangeException(nameof(startColumnIndex), $"Start column index must be between 1 and {CellReferenceConverter.MaxColumnNumber}."); @@ -143,17 +144,14 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC // convert to 0-based endRowIndex--; } + if (endColumnIndex.HasValue) { - if (endColumnIndex.Value is > 0 and <= CellReferenceConverter.MaxColumnNumber) - { - // convert to 0-based - endColumnIndex--; - } - else - { + if (endColumnIndex.Value is <= 0 or > CellReferenceConverter.MaxColumnNumber) throw new ArgumentOutOfRangeException(nameof(endColumnIndex), $"End column index must be between 1 and {CellReferenceConverter.MaxColumnNumber}."); - } + + // convert to 0-based + endColumnIndex--; } return InternalQueryRangeAsync(hasHeaderRow, sheetName, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, cancellationToken); @@ -229,11 +227,10 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC if (reader.IsStartElement("row", Ns)) { var nextRowIndex = rowIndex + 1; - var rowAttribute = reader.GetAttribute("r"); - if (rowAttribute is not null) + if (reader.GetAttribute("r") is { } rowAttribute) { - if (!int.TryParse(rowAttribute, NumberStyles.None, CultureInfo.InvariantCulture, out var rowNumber) - || rowNumber is < 1 or > CellReferenceConverter.MaxRowNumber) + if (!int.TryParse(rowAttribute, NumberStyles.None, CultureInfo.InvariantCulture, out var rowNumber) || + rowNumber is < 1 or > CellReferenceConverter.MaxRowNumber) { throw new InvalidDataException($"Row index '{rowAttribute}' is outside Excel's valid range."); } @@ -261,20 +258,17 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC break; } - if (!_config.IgnoreEmptyRows && _config.MaxSynthesizedCells is { } maxSynthesizedCells) + if (_config is { IgnoreEmptyRows: false, MaxSynthesizedCells: { } maxSynthesizedCells }) { var expectedRowIndex = isFirstRow ? startRowIndex : nextRowIndex; var emptyRowCount = Math.Max(0, rowIndex - expectedRowIndex); var columnCount = hasHeaderRow ? Math.Max(1, headRows.Count) : Math.Max(1, maxColumnIndex - startColumnIndex + 1); - synthesizedCellCount += (long)emptyRowCount * columnCount; + synthesizedCellCount += (long)emptyRowCount * columnCount; if (synthesizedCellCount > maxSynthesizedCells) - { - throw new InvalidDataException( - $"The worksheet exceeds the configured limit of {maxSynthesizedCells} synthesized empty cells."); - } + throw new InvalidDataException($"The worksheet exceeds the configured limit of {maxSynthesizedCells} synthesized empty cells."); } var query = QueryRowAsync(reader, isFirstRow, startRowIndex, nextRowIndex, rowIndex, @@ -655,6 +649,9 @@ protected async Task ReadCellAndSetColumnIndexAsync(XmlReader rea } else { + if (!string.IsNullOrEmpty(aR) && referenceColumn > CellReferenceConverter.MaxColumnNumber) + throw new InvalidDataException($"Cell reference '{aR}' is invalid."); + newColumnIndex = columnIndex; } From 22f60ac1a15f5f775212578b05df5843f70f2455 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 26 Sep 2026 19:06:03 +0200 Subject: [PATCH 3/3] Included empty row tags in the calculation of synthesized cells count --- src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index a146f96c..87a33008 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -266,7 +266,9 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC ? 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 > maxSynthesizedCells) throw new InvalidDataException($"The worksheet exceeds the configured limit of {maxSynthesizedCells} synthesized empty cells."); }