Skip to content

Bound the declared payload length and the domain on read and write - #4

Merged
jwrosewell merged 4 commits into
mainfrom
fix/payload-length-before-allocation
Aug 30, 2026
Merged

Bound the declared payload length and the domain on read and write#4
jwrosewell merged 4 commits into
mainfrom
fix/payload-length-before-allocation

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Defect and format rule

The parser previously accepted a payload length that left bytes after the 64-byte signature. This PR requires the declared payload to be followed by exactly the signature and nothing else. A declaration that does not agree with the bytes present is rejected before allocating the payload copy.

The unsigned 32-bit field itself permits payloads from 0 through 4,294,967,295 bytes. A large value is not malformed when the matching bytes are present. Java cannot represent every such value in one byte array, so implementation capacity and application policy are separate from format validity.

Current changes

  • Rejects payload-length mismatches, trailing bytes, and short signatures consistently with the .NET reference.
  • Documents the protocol range and tells callers to limit the complete transport body before buffering or Base64 decoding.
  • Adds getPayloadLength(), allowing downstream policy to inspect the structured payload size without the copy made by getPayload().
  • Renames the large-declaration test to describe the actual fault: the declared bytes are absent.
  • Adds a matching 1 MiB payload test so no arbitrary parser limit is introduced.
  • Reads unsigned 32-bit fields without a temporary array.
  • Pre-sizes serialization and signing buffers exactly and returns their backing array directly, avoiding buffer growth and the final full toByteArray() copy. Length arithmetic is checked before allocation.

A malformed envelope declaring 4 GiB but carrying only a few bytes still fails with work and memory proportional to the bytes actually supplied. A matching representable payload is processed in proportion to its real size.

Domain length bound

The creator domain is stored as text followed by a zero terminator, and the reader found the end of it by walking forward to that terminator. A missing or corrupted terminator sent the walk to the end of the buffer, so the cost of parsing was set by the length of the input rather than by the size of the field. That is the same class of attacker controlled work as the payload length above, and it was the last unbounded read left in the envelope parse.

RFC 1035 section 2.3.4, "Size limits", restricts the total length of a domain name to 255 octets or less. That number counts the wire format, which spends one length octet on every label and one zero octet on the root. An OWID stores the presentation form instead, being the text example.com, where the dots stand in for the label length octets and the root has no text at all, so the same published limit is two characters shorter here. The new constant MAXIMUM_DOMAIN_LENGTH in Io carries that reasoning next to the number.

  • The reader now stops at the maximum rather than at the end of the buffer, so an unterminated field costs no more than the maximum however long the buffer is.
  • A domain over the maximum is refused without reading past it.
  • The refusal reuses OwidException with a new message, because the module has a single exception type and no error variants to add to. No public API shape changes.
  • Nothing about a valid envelope changes, and every existing test passes untouched.

New tests in DomainLengthTest cover a domain at the maximum parsing and round tripping, one character over being refused, a buffer with no terminator anywhere being refused, a sixteen mebibyte domain field being refused while allocating under 64 KiB, and the library's own signed output still parsing and verifying. Reverting only the bound and rerunning fails overMaximumLengthDomainRefused and hostileDomainRefusedWithoutAllocating, so those two catch the defect. missingTerminatorRefused passes either way, because the old code also refused a buffer with no zero byte in it, and what the bound changes there is the work done rather than the outcome.

The same bound on the write

The read was bounded but the write was not, so a creator configured with a longer domain still produced an OWID that this same library would refuse to parse. A library that can emit what it cannot read leaves the fault to surface at the consumer rather than at the creator. The specification is being changed to match in owid#6, so that the limit binds a creator as well as a consumer, and this change makes the Java implementation enforce the half it owns.

The check sits in two places, both reusing MAXIMUM_DOMAIN_LENGTH and neither writing the number again.

  • Creator.create refuses a domain over the maximum as the caller supplies it, which is the earliest point at which a caller can be told. It sits before the check on whether the crypto instance can sign, so no OWID is built and no private key is used.
  • Io.writeString refuses a domain that reaches serialisation by another route, such as Owid.setDomain or the Owid(domain, date, payload) constructor. Neither of those declares throws OwidException today, so bounding them directly would have changed a public signature, whilst the serialisation check covers both routes without that.

Both raise OwidException through one shared factory, Io.domainTooLong(), which the read now uses as well, so the two halves report the one condition in the same words. The length counted is the UTF-8 bytes, being what the read counts as it walks to the terminator. An empty domain, and any domain the library accepts today at or under the maximum, behaves exactly as before.

Four more tests in DomainLengthTest cover a creator holding a domain of exactly the maximum signing an OWID that round trips and verifies, a creator refused one character over with the maximum named in the message, the refusal arriving before the private key is used, and serialisation refusing a domain that arrived by another route. The refusal before signing is shown two ways, being a verify-only crypto instance where the domain message comes back rather than the message about not being able to sign, and an OWID whose over long domain is refused as the bytes to sign are assembled, leaving the signature empty. Reverting only the two new checks and rerunning fails three of those four tests. maximumLengthDomainWritten passes either way by design, because what it guards is that nothing inside the accepted range changed.

Validation

Apache Maven 3.9.16, mvn test: 52 tests passed on JDK 21 with sources compiled at --release 8, up from 48 after the domain read bound and 43 before it. Main and test sources both compile at release 8, so the new code stays Java 8 compatible. The CI matrix runs the same suite on Java 8, 11, 17 and 21.

References: the format-validation fix is owid-dotnet#5; the .NET size-policy follow-up is owid-dotnet#6.

This change was produced with AI assistance under James Rosewell's direction and needs human review before merge.

…locating

Io.Reader.readByteArray took the sender's declared payload count and
passed it to readBytes, which checked the count against the end of the
buffer before sizing the copy, so this port never allocated from the
declared number alone. What it did not check was that the payload is
followed by exactly the 64 byte signature and nothing else. A declared
count short of the bytes present parsed, with the signature read from
the middle of the envelope and the remainder ignored, and an envelope
with bytes after the signature parsed with those bytes ignored. The
reference fix in owid-dotnet sets the rule every port now follows, so
the same malformed envelopes are refused the same way everywhere.

The count is now checked against the bytes present before anything is
sized by it. A valid OWID is the declared payload followed by the 64
byte signature and nothing else, so the count must equal the bytes
remaining less the signature length, and any other count, short or
long, is refused with the existing OwidException, which names the
declared length and the bytes present. Envelopes with a byte after the
signature, previously ignored, are now refused as malformed. The
separate Integer.MAX_VALUE check is gone because the new check covers
it. The other length driven reads were checked and none needed
changing, being the domain terminator scan, which stops at the end of
the buffer, and the date and signature reads, which go through the
bounded readBytes.

PayloadLengthTest covers a matching envelope, the library's own signed
output, off-by-one counts, a trailing byte, a short signature, declared
lengths of 64 MiB, 2 GiB and 0xFFFFFFFF each refused with under 64 KiB
allocated on the thread, and an empty payload. The Javadoc on
Owid.fromByteArray and on the serialized form now say the signature
ends the envelope.

Files changed:
src/main/java/com/swancommunity/owid/Io.java
src/main/java/com/swancommunity/owid/Owid.java
src/test/java/com/swancommunity/owid/PayloadLengthTest.java
The creator domain is stored as text followed by a zero terminator, and
the reader found the end of it by walking forward to that terminator. A
missing or corrupted terminator sent the walk to the end of the buffer,
so the cost of parsing was set by the length of the input rather than
by the size of the field, which is the same class of attacker
controlled work as the declared payload length this branch already
checks.

RFC 1035 section 2.3.4, "Size limits", restricts the total length of a
domain name to 255 octets or less. That number counts the wire format,
which spends one length octet on every label and one zero octet on the
root. An OWID stores the presentation form instead, being the text
"example.com", where the dots stand in for the label length octets and
the root has no text at all, so the same published limit is two
characters shorter here. The new constant MAXIMUM_DOMAIN_LENGTH in Io
carries that reasoning next to the number.

The reader now stops at the maximum rather than at the end of the
buffer, so an unterminated field costs no more than the maximum however
long the buffer is, and a domain over the maximum is refused without
reading past it. The refusal uses the existing OwidException with a new
message, because the module has one exception type and no error
variants to add to.

Nothing about a valid envelope changes. New tests cover a domain at the
maximum parsing and round tripping, one character over being refused, a
buffer with no terminator at all being refused, a sixteen mebibyte
domain field being refused while allocating under 64 KiB, and the
library's own signed output still parsing and verifying. The suite goes
from 43 to 48 tests, all passing.
@jwrosewell jwrosewell changed the title Check the declared payload length before allocating Bound the declared payload length and the domain read Aug 30, 2026
Reading an OWID has been bounded at the published maximum for a domain
name since the previous commit, but writing one was not, so a creator
configured with a longer domain still produced an OWID that this same
library would refuse to parse. A library that can emit what it cannot
read leaves the fault to surface at the consumer rather than at the
creator.

The one constant, Io.MAXIMUM_DOMAIN_LENGTH, now bounds both halves.
Creator.create refuses a domain over the maximum when the caller
supplies it, before the crypto instance is looked at and before any
OWID exists, and Io.writeString refuses one that reaches serialisation
by another route such as Owid.setDomain. Both raise the OwidException
the read raises, through one shared factory, so the two halves report
the one condition in the same words. The length counted is the UTF-8
bytes, being what the read counts as it walks to the terminator.

Nothing at or under the maximum behaves differently. Four tests cover
a creator holding a domain of exactly the maximum signing an OWID that
round trips and verifies, a creator refused one character over with
the maximum named in the message, the refusal arriving before the
private key is used, and serialisation refusing a domain that arrived
by another route. Removing only the two new checks fails three of the
four.
@jwrosewell jwrosewell changed the title Bound the declared payload length and the domain read Bound the declared payload length and the domain on read and write Aug 30, 2026
@jwrosewell
jwrosewell merged commit 77619af into main Aug 30, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant