Bound the declared payload length and the domain on read and write - #4
Merged
Conversation
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
getPayloadLength(), allowing downstream policy to inspect the structured payload size without the copy made bygetPayload().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 constantMAXIMUM_DOMAIN_LENGTHinIocarries that reasoning next to the number.OwidExceptionwith a new message, because the module has a single exception type and no error variants to add to. No public API shape changes.New tests in
DomainLengthTestcover 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 failsoverMaximumLengthDomainRefusedandhostileDomainRefusedWithoutAllocating, so those two catch the defect.missingTerminatorRefusedpasses 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_LENGTHand neither writing the number again.Creator.createrefuses 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.writeStringrefuses a domain that reaches serialisation by another route, such asOwid.setDomainor theOwid(domain, date, payload)constructor. Neither of those declaresthrows OwidExceptiontoday, so bounding them directly would have changed a public signature, whilst the serialisation check covers both routes without that.Both raise
OwidExceptionthrough 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
DomainLengthTestcover 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.maximumLengthDomainWrittenpasses 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.