The Case of the Phantom NUL Byte: A Two-Day Deep Dive Into a Business Central Platform Bug

Or: how a single stray 0x00 byte cost me roughly 10% of my monthly AI token budget, three wrong hypotheses, and two days of my life — and how it was never the bug I thought it was. Twice.

Chapter 1: “Invalid header line: \u0000”

It started, as these things do, with an error message that looked simple enough to fix in five minutes:

Failed to read the request form. Invalid header line: \u0000

We were calling a third-party REST API — one of those endpoints that wants a good old multipart/form-data upload: a file field, some text metadata, the works. Sometimes it was a binary file (an Excel .xlsx). Sometimes it was a text-based XML export. The AL code building the request was, on the surface, completely unremarkable: initialize a body, write some multipart headers, copy the file stream in, close the boundary, send it off.

Except every so often — not always, but often enough to be a real problem — the server would reject the request with a 400 Bad Request and that delightfully unhelpful \u0000 sitting right there in the error text. A literal NUL byte, apparently, sitting somewhere it had no business being, right in the middle of an HTTP header.

That’s the moment every developer knows: the “huh, that’s not supposed to happen” moment. Little did I know it would take two days, four competing theories, and a small fleet of purpose-built diagnostic codeunits to actually pin down.

Chapter 2: The First Suspect — HttpContent.WriteFrom(InStream)

The first, most obvious suspect was Business Central’s HttpContent.WriteFrom(InStream). A bit of research (and yes, decompiling the actual platform DLLs to read the real .NET source behind the AL API) turned up a plausible mechanism: the platform’s LoadFrom(NavInStream) implementation does a raw .NET Stream.CopyTo() on the underlying stream — bypassing AL’s own typed end-of-stream semantics. On paper, that’s exactly the kind of thing that could silently tack on a stray padding byte when copying from a BLOB-backed stream.

It fit. It was decompiled, documented, and looked airtight. We rewrote the multipart body construction to avoid WriteFrom(InStream) entirely, routing everything through WriteFrom(Text) instead — which meant first reading the entire buffered content back as Text before handing it to HttpContent.

That “fix” promptly created its own, much uglier problem for binary content: AL’s Read(Text) will silently substitute the Unicode replacement character (U+FFFD) for any byte sequence it can’t decode as valid text. For binary content like an Excel file, that’s not a formatting nuance — it’s permanent, irrecoverable data destruction. One corrupted byte becomes three garbage bytes on the way out. We spent a good chunk of time confirming this really was happening (hex-dumping the actual bytes, watching EF BF BD show up right where valid UTF-8 should have been), and confirming it really was a dead end for genuine binary content.

Chapter 3: The Base64 Detour

With the direct binary path looking untrustworthy, the next logical move was: what if we just never hand HttpContent a raw byte stream at all? Base64-encode everything, since base64 output is always plain ASCII and can never trigger the U+FFFD substitution problem.

This produced a small taxonomy of experimental “routes” — whole-body base64 with a Content-Transfer-Encoding header, base64 without that header, a JSON-wrapped base64 variant (mirroring a pattern that genuinely works on a different endpoint of the same API), and a multipart variant with just the file section base64-encoded. Tested one by one against the real server, each died a slightly different death: some got rejected outright with 415 Unsupported Media Type because the endpoint hard-requires genuine multipart bodies; the most promising one got past that check but came back with “the uploaded file has a length of zero bytes” — turns out modern multipart parsers don’t actually implement Content-Transfer-Encoding decoding (it was deprecated from the multipart spec years ago), so they just discard content they don’t know what to do with.

Dead end, but an instructive one: it proved the server side had zero tolerance for cleverness. Whatever we sent had to be the real, literal bytes.

Chapter 4: Building an Actual Test Matrix

At this point the sensible thing to do was stop guessing and start measuring. This is where the investigation turned from “try things against a live server and squint at error messages” into proper, isolated, byte-level science.

The methodology: build small, self-contained diagnostic codeunits that never touch the network, never touch Text decoding, and read every result back strictly byte-by-byte — Read(Byte) in a loop, counting NULs, recording exact positions. No ReadAs(Text), no TextBuilder, nothing that could mask or introduce a byte we didn’t expect. If a discrepancy showed up under that discipline, it was real.

The test matrix grew organically as each result raised a new question:

  • Does a pure binary blob, round-tripped through WriteFrom(InStream), come out byte-identical? (Yes — this actually cleared WriteFrom(InStream) as a suspect, which at the time felt like a plot twist, since it directly contradicted the original decompiled-source theory.)
  • Does the same hold when the blob mixes text headers with binary content, exactly like a real multipart body? (Needed its own dedicated test, building an independent “ground truth” byte sequence to compare against.)
  • Does the actual production code path — not a synthetic stand-in, the real thing — produce the same bytes as that ground truth? (This is where it got interesting: the real production path came back wildly, catastrophically wrong — not subtly corrupted, but truncated to a fraction of its expected size.)
  • Is it encoding-specific? Tested UTF-8 explicitly against the platform’s default encoding, side by side.
  • Is it about a missing explicit length argument (a previously-documented pitfall) — or does it happen even when the length is always passed explicitly? Tested both, one call at a time, multiple calls at a time, one giant consolidated call.
  • Does a byte-for-byte, one-Byte-at-a-time write path behave differently than a Text-based write? (This became the control group that finally cracked it.)

Ruling things out was just as valuable as ruling things in. Along the way, we confirmed AL’s Text primitive doesn’t silently swallow embedded NUL bytes (so it wasn’t secretly hiding a problem elsewhere), confirmed the corruption wasn’t tied to any particular TextEncoding, and confirmed the multipart framing itself wasn’t the culprit’s real home — it just happened to be where the symptom became visible, because it’s the one place in the code that made many separate, small, discrete write calls rather than one large bulk copy.

Chapter 5: The Actual Culprit

And there it was, staring out from a hex dump: NUL bytes landing at precisely the boundary after every single individual write call. Not randomly. Not sometimes. Every time, one extra 0x00 byte, immediately following whatever content had just been written — including, tellingly, as the literal very last byte of an entire request body, right after the final closing boundary line.

The actual root cause, once isolated with a clean, minimal, four-line reproduction: a specific low-level stream-writing primitive — the one responsible for writing a piece of text of a given length into a buffer — deterministically appends one extra byte beyond the length it was told to write. It doesn’t matter if you pass the length explicitly (which is supposed to be the safe, documented way to call it). It doesn’t matter which text encoding you use. It happens for a single call and it happens for many calls in a row, each one leaking its own extra byte. The one thing that reliably doesn’t leak anything is writing byte-by-byte instead of text-by-text.

Two days, four hypotheses, a mountain of decompiled platform source, a small zoo of purpose-built test codeunits, and it came down to: don’t use that particular write primitive for anything that needs to be byte-exact. Write the bytes yourself.

Chapter 6: The Fix

The actual code change, once we knew what to change, was almost anticlimactic: stop handing text straight to the buggy write primitive, and instead encode it to UTF-8 bytes ourselves — properly, including correct handling of the rare case where a character needs a multi-byte UTF-16 surrogate pair — and write those bytes one at a time through the proven-clean path. Everything downstream of that (the multipart assembly, the binary content copying, the final HTTP send) just started working, for both binary and text payloads, without any base64 tricks, without any custom text-reconstruction hacks, without any of the two days’ worth of scaffolding we’d built along the way.

All of that scaffolding — the experimental base64 routes, the diagnostic test codeunits, the debug event subscribers dumping raw request bytes — got deleted once the real fix was confirmed working end-to-end. The only trace left behind is a very long paper trail explaining exactly why each dead end looked promising at the time, so that nobody (including future-me) has to walk that road again.


TL;DR (technical summary)

Symptom: Building a multipart/form-data request body (mixed text headers + binary/text content) in AL and sending it via HttpContent intermittently produced stray 0x00 (NUL) bytes in the outgoing request, causing the receiving server to reject the request (Invalid header line: \u0000 from strict HTTP parsers) or, for binary payloads, to fail to parse the resulting file as corrupted.

False leads, ruled out with rigorous byte-level isolation testing (never using Read(Text)/ReadAs(Text) for verification, only raw Read(Byte) loops):

  1. HttpContent.WriteFrom(InStream) on a Temp Blob-backed stream — initially suspected via decompiled platform source (a raw .NET Stream.CopyTo() with no AL-level EOS awareness), but did not reproduce under isolated testing, even for mixed text+binary content. Not the bug.
  2. A hand-rolled Text-reconstruction fallback (reading a blob back as Text in chunks to avoid #1) — genuinely broken (silent U+FFFD substitution for undecodable binary byte sequences, plus observed truncation), but this was our own workaround code, not a platform issue, and was discarded once the real fix was found.
  3. Base64-encoding the payload to sidestep both of the above — technically sound on the client side, but the target API is strictly multipart-only with no support for Content-Transfer-Encoding or JSON-with-base64 alternatives; the server either rejected the content type outright or silently zeroed out the base64-encoded section.

Actual root causeOutStream.Write(Text: Text; Length: Integer), when writing to an OutStream obtained from Codeunit "Temp Blob", deterministically appends one stray trailing 0x00 byte after every single call — regardless of whether Length is passed explicitly (StrLen(Txt)), regardless of TextEncoding (UTF8 and default both reproduce identically), and regardless of call count (a single consolidated call also leaks exactly one byte, at the very end). Multipart bodies are especially exposed because they’re built from many small, separate write calls (boundary, headers, blank-line separators), so each one leaks its own NUL at the segment boundary. The single-byte OutStream.Write(Byte) overload does not exhibit this behavior.

Fix: Stop calling OutStream.Write(Text, Length) for anything that needs to be byte-exact. Encode the text to UTF-8 bytes manually (including correct surrogate-pair handling for non-BMP characters) and write it via OutStream.Write(Byte) in a loop instead. Combined with materializing the final HttpContent via WriteFrom(InStream) on the resulting blob (which — once isolated from the unrelated Write(Text) bug — is itself byte-exact), the whole pipeline becomes provably byte-for-byte correct. Filed as a platform bug with Microsoft, since the behavior directly contradicts the documented contract of the Write(Text; Integer) overload.


Sample implementation

In case you wonder how the solution basically looks, here a generated sample.

codeunit 50100 "Multipart File Body"
{
    // Builds a multipart/form-data HTTP body (headers/boundaries as text, file content as raw bytes)
    // and materializes it as HttpContent. All text is written via manual UTF-8 byte encoding rather than
    // OutStream.Write(Text, Length), and the body is converted to HttpContent via WriteFrom(InStream) -
    // both proven byte-exact, unlike the alternatives (see accompanying blog post for why).

    var
        TempBlob: Codeunit "Temp Blob";
        MainOutStream: OutStream;
        MainFilename: Text;
        Initialized: Boolean;

    /// <summary>
    /// Initializes the body with the given filename. Spaces are stripped because the filename is only
    /// used for multipart framing (Content-Disposition header and boundary derivation), not for the
    /// actual file content.
    /// </summary>
    procedure Initialize(Filename: Text)
    begin
        TempBlob.CreateOutStream(MainOutStream);
        MainFilename := Filename.Replace(' ', '');
        Initialized := true;
    end;

    /// <summary>
    /// Writes a single multipart section whose body is read from the given stream: opening frame
    /// (boundary, Content-Disposition, Content-Type, blank line), the raw file content, then the
    /// closing frame.
    /// </summary>
    procedure WriteMultipartContent(BodyAsStream: InStream; ContentDispositionFormDataTypeIdentifier: Text)
    begin
        WriteMultipartContentOpening(ContentDispositionFormDataTypeIdentifier);
        CopyStream(MainOutStream, BodyAsStream); // Bulk binary copy - no text encoding involved.
        WriteMultipartContentClosing();
    end;

    local procedure WriteMultipartContentOpening(ContentDispositionFormDataTypeIdentifier: Text)
    var
        ContentDispositionLbl: Label 'Content-Disposition: form-data; name="%1"; filename="%2"', Comment = '%1 = Identifier; %2 = Filename';
        ContentTypeLbl: Label 'Content-Type: application/%1', Comment = '%1 = Content type';
        NotInitializedErr: Label 'Call Initialize before writing multipart content.';
    begin
        if not Initialized then
            Error(NotInitializedErr);
        WriteLine('--' + GetMultipartBoundary(MainFilename));
        WriteLine(StrSubstNo(ContentDispositionLbl, ContentDispositionFormDataTypeIdentifier, MainFilename));
        WriteLine(StrSubstNo(ContentTypeLbl, ContentDispositionFormDataTypeIdentifier));
        WriteLine('');
    end;

    local procedure WriteMultipartContentClosing()
    begin
        WriteLine('');
        WriteLine('--' + GetMultipartBoundary(MainFilename) + '--');
    end;

    local procedure GetMultipartBoundary(Filename: Text): Text
    var
        CryptographyManagement: Codeunit "Cryptography Management";
    begin
        exit('------' + CryptographyManagement.GenerateHash(Filename, 0));
    end;

    /// <summary>
    /// Appends the given text to the internal stream followed by a CRLF line break.
    /// </summary>
    local procedure WriteLine(Txt: Text)
    var
        TypeHelper: Codeunit "Type Helper";
    begin
        Write(Txt + TypeHelper.CRLFSeparator());
    end;

    /// <summary>
    /// Appends the given text to the internal stream. Encodes to UTF-8 bytes itself and writes via
    /// OutStream.Write(Byte) rather than OutStream.Write(Text, Length), which on a Temp Blob-backed
    /// stream appends a stray trailing 0x00 byte after every call regardless of the length passed.
    /// </summary>
    local procedure Write(Txt: Text)
    begin
        WriteTextAsUtf8Bytes(Txt);
    end;

    /// <summary>
    /// Encodes the given text to UTF-8 bytes and writes them one byte at a time via OutStream.Write(Byte).
    /// Handles the full Unicode range, including characters outside the Basic Multilingual Plane
    /// represented as UTF-16 surrogate pairs (combined into a single 4-byte UTF-8 sequence).
    /// </summary>
    local procedure WriteTextAsUtf8Bytes(Txt: Text)
    var
        CharIndex, TextLength : Integer;
        CodePoint, LowSurrogate, CombinedCodePoint : Integer;
        ByteValue: Byte;
        IsSurrogatePair: Boolean;
    begin
        TextLength := StrLen(Txt);
        CharIndex := 1;
        while CharIndex <= TextLength do begin
            CodePoint := GetCharCodePoint(Txt, CharIndex);
            IsSurrogatePair := false;

            if (CodePoint >= 55296) and (CodePoint <= 56319) and (CharIndex < TextLength) then begin
                LowSurrogate := GetCharCodePoint(Txt, CharIndex + 1);
                if (LowSurrogate >= 56320) and (LowSurrogate <= 57343) then
                    IsSurrogatePair := true;
            end;

            if IsSurrogatePair then begin
                CombinedCodePoint := 65536 + ((CodePoint - 55296) * 1024) + (LowSurrogate - 56320);
                ByteValue := 240 + (CombinedCodePoint div 262144);
                MainOutStream.Write(ByteValue);
                ByteValue := 128 + ((CombinedCodePoint div 4096) mod 64);
                MainOutStream.Write(ByteValue);
                ByteValue := 128 + ((CombinedCodePoint div 64) mod 64);
                MainOutStream.Write(ByteValue);
                ByteValue := 128 + (CombinedCodePoint mod 64);
                MainOutStream.Write(ByteValue);
                CharIndex += 2;
            end else begin
                if CodePoint < 128 then begin
                    ByteValue := CodePoint;
                    MainOutStream.Write(ByteValue);
                end else
                    if CodePoint < 2048 then begin
                        ByteValue := 192 + (CodePoint div 64);
                        MainOutStream.Write(ByteValue);
                        ByteValue := 128 + (CodePoint mod 64);
                        MainOutStream.Write(ByteValue);
                    end else begin
                        ByteValue := 224 + (CodePoint div 4096);
                        MainOutStream.Write(ByteValue);
                        ByteValue := 128 + ((CodePoint div 64) mod 64);
                        MainOutStream.Write(ByteValue);
                        ByteValue := 128 + (CodePoint mod 64);
                        MainOutStream.Write(ByteValue);
                    end;
                CharIndex += 1;
            end;
        end;
    end;

    /// <summary>
    /// Returns the Unicode code point of the character at the given 1-based position in Txt.
    /// </summary>
    local procedure GetCharCodePoint(Txt: Text; Index: Integer): Integer
    var
        SingleCharText: Text;
        SingleChar: Char;
    begin
        SingleCharText := CopyStr(Txt, Index, 1);
        foreach SingleChar in SingleCharText do
            exit(SingleChar);
    end;

    /// <summary>
    /// Returns a raw InStream over the buffered content, positioned at the start.
    /// </summary>
    local procedure GetStream(): InStream
    var
        InStr: InStream;
    begin
        TempBlob.CreateInStream(InStr);
        InStr.ResetPosition();
        exit(InStr);
    end;

    /// <summary>
    /// Materializes the buffered content as HttpContent with a multipart/form-data content type.
    /// </summary>
    procedure ToHttpContentMultipart(): HttpContent
    var
        MultipartHeaderLbl: Label 'multipart/form-data; boundary=%1', Comment = '%1 = Boundary';
        NotInitializedErr: Label 'Call Initialize before converting to HttpContent.';
    begin
        if not Initialized then
            Error(NotInitializedErr);
        exit(ToHttpContent(StrSubstNo(MultipartHeaderLbl, GetMultipartBoundary(MainFilename))));
    end;

    /// <summary>
    /// Materializes the buffered content as HttpContent with the given content type. Uses
    /// WriteFrom(InStream) rather than a Text round-trip, since the buffer was never populated via
    /// OutStream.Write(Text, Length) - the resulting InStream is a byte-exact copy of the buffer.
    /// </summary>
    procedure ToHttpContent(ContentTypeValue: Text): HttpContent
    var
        Content: HttpContent;
        Headers: HttpHeaders;
    begin
        Content.WriteFrom(GetStream());
        Content.GetHeaders(Headers);
        Headers.Clear();
        Headers.Add('Content-Type', ContentTypeValue);
        exit(Content);
    end;
}

Usage example, to show it end to end:

procedure Example()
var
    Body: Codeunit "Multipart File Body";
    FileInStream: InStream;
    Content: HttpContent;
begin
    // ... obtain FileInStream from wherever your file comes from ...
    Body.Initialize('example.xlsx');
    Body.WriteMultipartContent(FileInStream, 'file');
    Content := Body.ToHttpContentMultipart();
    // ... attach Content to an HttpRequestMessage and send ...
end;

P.S.: And of course this post was written with the help of Copilot. Even with the help of Copilot most of my time this week went down the drain to find a solution for this problem. So this post wouldn’t exist without the help of Copilot. But it covers the last two days very good 😊

Leave a Comment