diff --git a/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs b/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs
index 7decb5809..2edb9a890 100644
--- a/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs
+++ b/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs
@@ -56,7 +56,12 @@ public StreamPackageFeature(Stream stream, PackageOpenMode openMode, bool isOwne
}
catch when (isOwned)
{
- // Ensure that the stream if created is disposed before leaving the constructor so we don't hold onto it
+ if (_stream is not null && OpenXmlPackage.IsEncryptedOfficeFile(_stream))
+ {
+ _stream.Dispose();
+ throw new OpenXmlPackageException(ExceptionMessages.EncryptedPackageNotSupported);
+ }
+
_stream?.Dispose();
throw;
}
diff --git a/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs b/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs
index c8ce6524a..b85b9fe17 100644
--- a/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs
+++ b/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs
@@ -612,5 +612,97 @@ public void Save()
///
public override IFeatureCollection Features => _features ??= new PackageFeatureCollection(this);
+
+ ///
+ /// Determines whether the provided stream represents an encrypted Office Open XML file.
+ ///
+ /// The to check. The stream must be seekable and not null.
+ ///
+ /// true if the stream is an encrypted Office file (either OLE Compound File or contains an encrypted package part); otherwise, false.
+ ///
+ /// Thrown if is null.
+ /// Thrown if is not seekable.
+ ///
+ /// This method checks for the OLE Compound File signature at the start of the stream, which is used for encrypted Office files.
+ /// If not found, it attempts to open the stream as an OPC package and checks for the presence of an encrypted package part.
+ /// The stream position is restored after the check.
+ ///
+ public static bool IsEncryptedOfficeFile(Stream inputStream)
+ {
+ if (inputStream is null)
+ {
+ throw new ArgumentNullException(nameof(inputStream));
+ }
+
+ if (!inputStream.CanSeek)
+ {
+ throw new ArgumentException("Stream must be seekable.");
+ }
+
+ long originalPosition = inputStream.Position;
+
+ try
+ {
+ byte[] header = new byte[8];
+ inputStream.Seek(0, SeekOrigin.Begin);
+ int read = inputStream.Read(header, 0, header.Length);
+ inputStream.Seek(originalPosition, SeekOrigin.Begin);
+
+ // OLE Compound File signature for encrypted Office files
+ if (read == 8 && header.SequenceEqual(new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }))
+ {
+ return true;
+ }
+
+ // If not OLE, try to open as package and check for encrypted part
+ try
+ {
+ using (var package = System.IO.Packaging.Package.Open(inputStream, FileMode.Open, FileAccess.Read))
+ {
+ foreach (var part in package.GetParts())
+ {
+ if (part.ContentType.Equals("application/vnd.openxmlformats-officedocument.encrypted-package", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+ }
+ catch
+ {
+ return false;
+ }
+
+ return false;
+ }
+ finally
+ {
+ inputStream.Seek(originalPosition, SeekOrigin.Begin);
+ }
+ }
+
+ ///
+ /// Determines whether the file at the specified path is an encrypted Office Open XML file.
+ ///
+ /// The path to the file to check. Must not be null.
+ ///
+ /// true if the file is an encrypted Office file (either OLE Compound File or contains an encrypted package part); otherwise, false.
+ ///
+ /// Thrown if is null.
+ ///
+ /// This method opens the file at the specified path and checks its contents using .
+ ///
+ public static bool IsEncryptedOfficeFile(string filePath)
+ {
+ if (filePath is null)
+ {
+ throw new ArgumentNullException(nameof(filePath));
+ }
+
+ using (FileStream fileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
+ {
+ return IsEncryptedOfficeFile(fileStream);
+ }
+ }
}
}
diff --git a/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt b/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt
index abbee3a8e..c128a4b58 100644
--- a/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt
+++ b/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt
@@ -1010,3 +1010,5 @@ DocumentFormat.OpenXml.OpenXmlPartWriterSettings.OpenXmlPartWriterSettings() ->
DocumentFormat.OpenXml.OpenXmlPartWriter.OpenXmlPartWriter(DocumentFormat.OpenXml.Packaging.OpenXmlPart! openXmlPart, DocumentFormat.OpenXml.OpenXmlPartWriterSettings! settings) -> void
DocumentFormat.OpenXml.OpenXmlPartWriter.OpenXmlPartWriter(System.IO.Stream! partStream, DocumentFormat.OpenXml.OpenXmlPartWriterSettings! settings) -> void
virtual DocumentFormat.OpenXml.OpenXmlCompositeElement.IsValidChild(DocumentFormat.OpenXml.OpenXmlElement! element) -> bool
+static DocumentFormat.OpenXml.Packaging.OpenXmlPackage.IsEncryptedOfficeFile(System.IO.Stream! inputStream) -> bool
+static DocumentFormat.OpenXml.Packaging.OpenXmlPackage.IsEncryptedOfficeFile(string! filePath) -> bool
diff --git a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs
index cf1eaed38..fb33da11e 100644
--- a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs
+++ b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs
@@ -19,7 +19,7 @@ namespace DocumentFormat.OpenXml {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class ExceptionMessages {
@@ -258,6 +258,15 @@ internal static string EmptyCollection {
}
}
+ ///
+ /// Looks up a localized string similar to Encrypted packages are not supported..
+ ///
+ internal static string EncryptedPackageNotSupported {
+ get {
+ return ResourceManager.GetString("EncryptedPackageNotSupported", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The contentType parameter has incorrect value..
///
diff --git a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx
index b0aaa58a4..a83880a81 100644
--- a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx
+++ b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx
@@ -414,4 +414,7 @@
The enumerable contained more than a single element when only zero or one are allowed.
+
+ Encrypted packages are not supported.
+
\ No newline at end of file
diff --git a/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs b/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs
index 1a98fd917..753367d77 100644
--- a/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs
+++ b/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs
@@ -11,6 +11,8 @@
using System.Linq;
using Xunit;
+using static DocumentFormat.OpenXml.Tests.TestAssets;
+
namespace DocumentFormat.OpenXml.Features.Tests;
public class StreamPackageFeatureTests
@@ -431,6 +433,17 @@ protected override void Dispose(bool disposing)
}
}
+ [Fact]
+ public void ThrowsForEncryptedOfficeFile()
+ {
+ using (Stream stream = GetStream(TestFiles.Encrypted_pptx, false))
+ {
+ // Act & Assert
+ var ex = Assert.Throws(() => new StreamPackageFeature(stream, PackageOpenMode.Read, isOwned: true));
+ Assert.Equal(ExceptionMessages.EncryptedPackageNotSupported, ex.Message);
+ }
+ }
+
private static readonly PartInfo Part1 = new(new("/part1", UriKind.Relative), "type1/content");
private static readonly PartInfo Part2 = new(new("/part2", UriKind.Relative), "type2/content");
private static readonly PartInfo PartRels = new(new("/_rels/.rels", UriKind.Relative), "application/vnd.openxmlformats-package.relationships+xml");
diff --git a/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs b/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs
index 3bb51863d..589390087 100644
--- a/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs
+++ b/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs
@@ -342,5 +342,61 @@ public void SucceedWithMissingCalcChainPart()
Assert.NotNull(spd);
}
+
+ [Fact]
+ public void IsEncryptedOfficeFile_ReturnsTrue_ForEncryptedFile()
+ {
+ using (Stream stream = GetStream(TestFiles.Encrypted_pptx, false))
+ {
+ Assert.True(OpenXmlPackage.IsEncryptedOfficeFile(stream));
+ }
+ }
+
+ [Fact]
+ public void IsEncryptedOfficeFile_ReturnsFalse_ForUnencryptedFile()
+ {
+ using (Stream stream = GetStream(TestFiles.Presentation, false))
+ {
+ Assert.False(OpenXmlPackage.IsEncryptedOfficeFile(stream));
+ }
+ }
+
+ [Fact]
+ public void IsEncryptedOfficeFile_ThrowsArgumentNullException_ForNullStream()
+ {
+ Assert.Throws(() => OpenXmlPackage.IsEncryptedOfficeFile((Stream)null!));
+ }
+
+ [Fact]
+ public void IsEncryptedOfficeFile_ThrowsArgumentException_ForUnseekableStream()
+ {
+ var unseekable = new UnseekableStream();
+ Assert.Throws(() => OpenXmlPackage.IsEncryptedOfficeFile(unseekable));
+ }
+
+ private class UnseekableStream : MemoryStream
+ {
+ public override bool CanSeek => false;
+ }
+
+ [Fact]
+ public void IsEncryptedOfficeFile_ReturnsTrue_ForEncryptedFilePath()
+ {
+ string filePath = GetTestFilePath(TestFiles.Encrypted_pptx);
+ Assert.True(OpenXmlPackage.IsEncryptedOfficeFile(filePath));
+
+ // Clean up the test file path
+ File.Delete(filePath);
+ }
+
+ [Fact]
+ public void IsEncryptedOfficeFile_ReturnsFalse_ForUnencryptedFile_FromString()
+ {
+ string filePath = GetTestFilePath(TestFiles.Presentation);
+ Assert.False(OpenXmlPackage.IsEncryptedOfficeFile(filePath));
+
+ // Clean up the test file path
+ File.Delete(filePath);
+ }
}
}
diff --git a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs
index f19a7a182..336759bcb 100644
--- a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs
+++ b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs
@@ -127,6 +127,8 @@ public static class Templates
public const string Of16_09_unknownelement_docx = "TestFiles.Of16-09-UnknownElement.docx";
public const string Of16_10_symex_docx = "TestFiles.Of16-10-SymEx.docx";
+
+ public const string Encrypted_pptx = "TestFiles.encrypted_pptx.pptx";
}
}
}
diff --git a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs
index a52f09bfb..e5e29a5d5 100644
--- a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs
+++ b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs
@@ -71,6 +71,26 @@ public static Stream GetStream(string name, bool isEditable)
return isEditable ? stream.AsMemoryStream() : stream;
}
+ ///
+ /// Extracts an embedded test resource to a temporary file and returns its file path.
+ ///
+ /// The name of the embedded resource to extract.
+ /// The full path to the temporary file containing the resource data.
+ ///
+ /// The caller is responsible for deleting the temporary file after use.
+ ///
+ public static string GetTestFilePath(string resourceName)
+ {
+ string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(resourceName));
+
+ using (Stream stream = GetStream(resourceName, false))
+ using (FileStream fileStream = File.Create(tempPath))
+ {
+ stream.CopyTo(fileStream);
+ return tempPath;
+ }
+ }
+
private static Stream AsMemoryStream(this Stream stream)
{
if (stream is MemoryStream ms)
diff --git a/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestFiles/encrypted_pptx.pptx b/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestFiles/encrypted_pptx.pptx
new file mode 100644
index 000000000..2d5228fb4
Binary files /dev/null and b/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestFiles/encrypted_pptx.pptx differ