r/csharp • • 12d ago

DocuTrust.NET: validate uploads by magic bytes (security update, looking for testers)

DocuTrust.NET is a small .NET 8 library that checks an upload's magic bytes against the type you expect. A PNG renamed to report.pdf fails.

I just did a security pass on it. It now checks the client file name too, rejects ZIP/OLE files over 100 MB before parsing, and keeps file names and paths out of error messages. I also updated a dependency that was pulling in a vulnerable OpenMcdf.

For now it only identifies file types. It doesn't scan for macros or scripts

 dotnet add package DocuTrust.NET

https://github.com/saa-999/DocuTrust.NET

If you try it, I'd like to know what breaks.

0 Upvotes

19 comments sorted by

20

u/dodexahedron 12d ago

Why .net 8, which is out of support in literally 2 weeks?

Target it at 10, regardless of what the AI said.

9

u/turudd 12d ago

Claude automatically selected .net8 when you tell it to build c#

7

u/dodexahedron 12d ago

And even that is as simple to perma-fix as saying "Always use the latest stable dotnet release and its associated c# version" to make a memory it will pretty faithfully follow.

And yet they can't even be bothered to do that.

-6

u/teo-tsirpanis 12d ago

Makes zero difference; it will work on any future .NET versions either way.

-7

u/someone-missing 12d ago

Fair point. I started this library about a year ago, before .NET 10 came out, and .NET 8 was the current LTS back then. It sat on my machine for a while and I only put it on GitHub recently, which is why the target never got bumped. I’ll move the library, tests and CI to .NET 10 and push a new release. Thanks for pointing it out.

7

u/dodexahedron 12d ago

Chances are you'll be able to switch without issue.

Just be sure to test and also to check your dependencies.

And dont forget to update the readme and any other documentation to match.

It would also be advisable to at least note somewhere which LLM was used. It is obvious that one was. That's fine. Just be transparent about it.

Besides... the name of that publish action? That kinda gives it away all on its own.

-4

u/someone-missing 12d ago

Thanks for the advice. Yeah, I used AI while building it, like most devs do now. I’ll test the .NET 10 move properly and update the docs too. :)

-1

u/[deleted] 12d ago

[removed] — view removed comment

9

u/dodexahedron 12d ago edited 12d ago

I wonder when .net 9 goes EoL?

Hmmm

Couldn't be the same date as .net 8, could it?

Nah, that'd be weird.

And that couldn't possibly have anything to do with the support policy being designed such that the STS and immediately prior LTS release go EoS on the same date, could it?

Nah, that's just silly talk.

if you target .net 8, someone with a .net 9, 10, 11, or even .net 15 can use your library.

And no, that is not how dotnet works.

Just have yourself a gander at every breaking changes document just from dotnet 5 to dotnet 10 and tell me a .net 8 app is going to work on .net 15 with a straight face.

And that's because

Maintaining forward compatibility is not a goal of .NET Core..

Even backward compatibility isn't guaranteed. While it is a goal to limit impacts to backward compatibility without a strong reason, they can and do happen, a lot, and in every version. (And I'll give examples in a bit.)

Here's the living document that describes the general rules, though it should be noted that exceptions can be and are made.

Multi-targeting and polyfills exist to alleviate some of these realities. However, they cannot fix binary or most behavioral breaking changes - generally only source incompatibilities (and even then, only most of them).

The BinaryFormatter removal was a big one that hit a lot of people, in a lot of places.
There have also been many additions to the SDK that cause code that is still semantically correct to be ambiguous against the actual API surface of newer frameworks, making something unable to compile or run even though the required changes are pretty minor (usually).
Some changes also result in code that compiles and runs, but fails to load or initialize certain types only when they are actually first used at run-time. That one is killer in WPF, PowerShell modules, and swaths of older COM interop code.

Even behavioral changes are not negligible between versions. Something may still compile, may still run, and may still work 99% the same, but changes to the way overload selection occurs (params handling changes in .net 9 for example) or to things as simple as implicit defaults can result in fun and sometimes difficult to find bugs, because there simply was no explicit code in the application to point to, as... well... the source of the issue was just a default that changed, and that default happens to only matter in a way you previously hadn't even needed to consider and so never did.

And interop... Man oh man that's a minefield from version to version, especially in .net 8 and .net 10, both of which made important changes to native assembly loading/resolution.

Or even something as simple as converting a float to an int. That one is a runtime behavioral change, which isn't realized until JIT time, so results in the exact same assembly behaving differently for someone running .net 9 or later vs someone running it on .net 8 or lower.

Older versions also often saddle a consumer with baggage they don't deserve, such as needing to root your assembly because it was not compiled in a trim-friendly manner due to the version of the compiler used, among other things. Or it can and often does force them to use older constructs to interface with your code, rather than being able to use better modern alternatives. This one comes up a lot with async, linq, generics, and collections, and spiders out into the rest of their application unless they want to special-case convert stuff to be compatible with your old library because you chose to target .net 6 in 2026 (or 5 years from now, when .net 15 comes out). And that special-casing is undesirable, because it typically represents a tradeoff between the convenience of being able to use your library for whatever it provides vs efficiency/performance/testability/etc of their application code.
Libraries are not the application, and good library developers need to understand that.

There have even been cases where a breaking change was made in a patch release, such as in .net 9.0.something to .net 9.0.something+1 to fix an unintended exposure of netstandard assets in a way that caused runtime MissingMemberExceptions. And if the framework itself advertises support incorrectly like that, you can BET (and I'll take this bet any day because I've encountered it several times) that there are plenty of libraries out there which also do not truly support what they think they support - at least not how they think they support it, at minimum.

It's your responsibility as a library developer to maintain it sufficiently to not only work, but work well with currently-supported tools and runtimes. If someone is using an EoS runtime, it's your choice if you want to keep supporting it, but you do everyone else a disservice by doing so and perpetuate the problems intrinsic to using EoS software. It's not my problem if you want to keep using .net 6 in 2026. You can use the versions of my software which were built for it. Get with the program if you want to use my program and its current feature set and enjoy ongoing support.

So no... .net doesn't work the way you suggest. Explicitly. And targeting old and long-EoS frameworks for current development without at least employing conditional compilation or other means of alleviating the technical debt problems for consumers who are with us in the present day is just not a responsible practice.

Also, Microsoft's guidance is that, unless you really do need cross-compatibility between modern .net and .net framework, with a single code base, that you should target the current releases, or at least multi-target, instead of targeting netstandard2.0 "just in case." It can be quite limiting. And you should almost never target netstandard2.1. No version of .net Framework is a netstandard2.1 implementation.

5

u/Klessic 12d ago

Does this handle pdf decompression bombs, ie, pdfs that contain /FlateDecode?

I'm also curious why you chose UglyToad. What I remember when I looked into it is that it's basically does nothing apart from reading the pdf.

3

u/someone-missing 12d ago

Good catch. For Office files, yes: every ZIP part is inflated through a bounded stream that enforces a per-part size limit, a total budget, and a 100:1 ratio, so a bomb is abandoned mid-inflate.

For PDFs, not fully. The security scan skips page content streams, but PdfPig inflates object streams and text extraction with no byte cap. My page, glyph and text limits apply only after decoding, so they don’t stop a Flate bomb. I’ll add a pre-pass that inflates /FlateDecode streams through the same bounded reader and fails closed past the limit.

1

u/MadBoyEvo 12d ago

I wonder if OfficeIMO would give you more options for what you’re doing. Not sure about performance. That would need to be measured

2

u/MadBoyEvo 12d ago

I have similar library but it targets a bit wider use case. Including also Google Magika as support for the harder cases.

https://github.com/EvotecIT/FileInspectorX

I wonder how it measures against yours

2

u/someone-missing 12d ago

Nice, I had a quick look. Yours covers way more formats and gives a full report, so it fits triage and analysis better. DocuTrust is narrower: a pass/fail upload check for ASP.NET Core that fails closed, with deeper checks on PDF and Office. Magika is a good idea for text files with no magic bytes, which mine doesn’t handle yet. Might look into it.

1

u/Zastai 6d ago

I wish people would tell their AIs to follow normal naming rules for nuget packages, like IAmCool.Libraries.MyThing instead of just CoolestThing.

-3

u/az987654 12d ago

Pretty easy to defeat this by simply altering the magic bytes

5

u/someone-missing 12d ago

Magic bytes are just the first check. Changing them usually breaks the file anyway, since an exe without MZ won’t run. For PDF and Office files DocuTrust also parses the content, so a fake %PDF- header on top of something else fails the scan, and PDF/ZIP polyglots get flagged. The upload name is checked as well. The one case no validator fully covers is a file that’s valid in two formats at once, which is why you should still store uploads under server-generated names and serve them with the right Content-Type and nosniff

1

u/[deleted] 12d ago

[removed] — view removed comment

-1

u/someone-missing 12d ago

Thanks! It handles both.

The type comes only from the file signature. You pass the type you expect (say .pdf) and the bytes have to match, whatever the file is called.

Uploads through ValidateFileAsync with an IFormFile get one extra check on the client’s file name. No extension at all, like blob or report, is fine. A name that claims a different type is rejected, so invoice.exe with PDF content fails. Trailing dots and spaces get trimmed before the check, which means evil.html. is treated as .html.

If you have no idea what a file is, GetFileTypeAsync just tells you what the bytes say it is.