Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions conformance/fixtures/claims/numeric-name.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "numeric-name",
"description": "Decimal claim names remain valid names even when a host language stores them as integer map keys.",
"input": {
"1": "one",
"0": "zero"
},
"expected": "0:zero\n1:one\n"
}
4 changes: 2 additions & 2 deletions conformance/fixtures/jcs/ecmascript-numbers.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ecmascript-numbers",
"description": "Numbers use ECMAScript serialization at fixed and exponential notation boundaries.",
"input": "{\"z\":-0,\"a\":1e30,\"b\":4.50,\"c\":2e-3,\"d\":1e-7,\"e\":1e-6}",
"expected": "{\"a\":1e+30,\"b\":4.5,\"c\":0.002,\"d\":1e-7,\"e\":0.000001,\"z\":0}"
"input": "{\"a\":1e30,\"b\":4.50,\"c\":2e-3,\"d\":1e-7,\"e\":1e-6}",
"expected": "{\"a\":1e+30,\"b\":4.5,\"c\":0.002,\"d\":1e-7,\"e\":0.000001}"
}
6 changes: 6 additions & 0 deletions conformance/fixtures/jcs/malformed-surrogate-json.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "malformed-surrogate-json",
"description": "Malformed JSON takes precedence over lone-surrogate classification.",
"input": "{\"value\":\"\\uD800",
"error": "jcs-invalid-json"
}
6 changes: 6 additions & 0 deletions conformance/fixtures/jcs/negative-underflow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "negative-underflow",
"description": "RFC 8785 erratum 7920 rejects a negative number that underflows to binary64 negative zero.",
"input": "{\"value\":-1e-400}",
"error": "jcs-number"
}
6 changes: 6 additions & 0 deletions conformance/fixtures/jcs/negative-zero.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "negative-zero",
"description": "RFC 8785 erratum 7920 rejects a literal negative zero token.",
"input": "{\"value\":-0}",
"error": "jcs-number"
}
7 changes: 7 additions & 0 deletions conformance/fixtures/jcs/oversized-malformed-json.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "oversized-malformed-json",
"description": "The source ceiling takes precedence over malformed JSON classification.",
"input": "{",
"repeat": 1048577,
"error": "resource-limit-exceeded"
}
4 changes: 2 additions & 2 deletions conformance/fixtures/jcs/rfc8785-appendix-b-numbers.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rfc8785-appendix-b-numbers",
"description": "Binary64 values use the ECMAScript spellings listed in RFC 8785 Appendix B.",
"input": "[0,-0,5e-324,-5e-324,1.7976931348623157e308,-1.7976931348623157e308,9007199254740992,-9007199254740992,295147905179352830000,9.999999999999997e22,1e23,1.0000000000000001e23,999999999999999700000,999999999999999900000,1e21,9.999999999999997e-7,0.000001,333333333.3333332,333333333.33333325,333333333.3333333,333333333.3333334,333333333.33333343,-0.0000033333333333333333,1424953923781206.25]",
"expected": "[0,0,5e-324,-5e-324,1.7976931348623157e+308,-1.7976931348623157e+308,9007199254740992,-9007199254740992,295147905179352830000,9.999999999999997e+22,1e+23,1.0000000000000001e+23,999999999999999700000,999999999999999900000,1e+21,9.999999999999997e-7,0.000001,333333333.3333332,333333333.33333325,333333333.3333333,333333333.3333334,333333333.33333343,-0.0000033333333333333333,1424953923781206.2]"
"input": "[0,5e-324,-5e-324,1.7976931348623157e308,-1.7976931348623157e308,9007199254740992,-9007199254740992,295147905179352830000,9.999999999999997e22,1e23,1.0000000000000001e23,999999999999999700000,999999999999999900000,1e21,9.999999999999997e-7,0.000001,333333333.3333332,333333333.33333325,333333333.3333333,333333333.3333334,333333333.33333343,-0.0000033333333333333333,1424953923781206.25]",
"expected": "[0,5e-324,-5e-324,1.7976931348623157e+308,-1.7976931348623157e+308,9007199254740992,-9007199254740992,295147905179352830000,9.999999999999997e+22,1e+23,1.0000000000000001e+23,999999999999999700000,999999999999999900000,1e+21,9.999999999999997e-7,0.000001,333333333.3333332,333333333.33333325,333333333.3333333,333333333.3333334,333333333.33333343,-0.0000033333333333333333,1424953923781206.2]"
}
7 changes: 4 additions & 3 deletions ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ mod capi {
Some(bytes) => match std::str::from_utf8(bytes) {
Ok(base) => Some(base),
Err(_) => {
let bytes = b"invalid-utf8".to_vec().into_boxed_slice();
let bytes = b"parser-profile-unsupported".to_vec().into_boxed_slice();
*out_len = bytes.len();
*out = Box::into_raw(bytes) as *mut u8;
return 1;
Expand All @@ -190,7 +190,7 @@ mod capi {
},
)
}
Err(_) => Err("invalid-utf8".to_string()),
Err(_) => Err("parser-profile-unsupported".to_string()),
};
let (status, bytes) = match result {
Ok(text) => (0, text.into_bytes()),
Expand Down Expand Up @@ -261,7 +261,8 @@ mod capi {
}

/// Normalize a UTF-8 byte string with the profile-v1 size limits.
/// Status 0 returns normalized text, status 1 returns `invalid-utf8` or
/// Status 0 returns normalized text, status 1 returns
/// `parser-profile-unsupported` or
/// `resource-limit-exceeded`, and status 2 indicates invalid pointers.
/// Valid output pointers are initialized before input decoding.
#[no_mangle]
Expand Down
2 changes: 1 addition & 1 deletion go/canonicalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func NormalizeTextChecked(text string, opts ...Options) (string, error) {
return "", fmt.Errorf("resource-limit-exceeded")
}
if !utf8.ValidString(text) {
return "", fmt.Errorf("invalid-utf8")
return "", fmt.Errorf("parser-profile-unsupported")
}
result := normalizeText(text, opts...)
if len(result) > maxResourceBytes {
Expand Down
25 changes: 25 additions & 0 deletions go/canonicalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,23 @@ func TestExtractClaimsFromSignedSectionRejectsNormalizedDuplicate(t *testing.T)
}
}

func TestExtractClaimsFromSignedSectionSelectsFirstNestedSection(t *testing.T) {
claims, err := ExtractClaimsFromSignedSection(`<div><signed-section><meta name="author" content="Nested first"></signed-section></div><signed-section><meta name="author" content="Later"></signed-section>`)
if err != nil {
t.Fatalf("ExtractClaimsFromSignedSection: %v", err)
}
if claims["author"] != "Nested first" {
t.Fatalf("selected claims = %#v, want first nested signed-section", claims)
}
}

func TestExtractClaimsFromSignedSectionChecksFieldSizeBeforeDuplicate(t *testing.T) {
_, err := ExtractClaimsFromSignedSection(`<meta name="author" content="A"><meta name="author" content="` + strings.Repeat("x", maxClaimFieldBytes+1) + `">`)
if err == nil || !strings.Contains(err.Error(), "resource-limit-exceeded") {
t.Fatalf("oversized duplicate claim error = %v, want resource-limit-exceeded", err)
}
}

func TestResolveKeyChain(t *testing.T) {
pemStr, _, _ := newEd25519PEM(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -826,6 +843,14 @@ func TestCanonicalizeEndorsementDocumentRejectsDuplicateMembers(t *testing.T) {
}
}

func TestCanonicalizeJSONDocumentRejectsNegativeZero(t *testing.T) {
for _, document := range []string{`{"value":-0}`, `{"value":-1e-400}`} {
if _, err := CanonicalizeJSONDocument([]byte(document)); err == nil || !strings.Contains(err.Error(), "jcs-number") {
t.Errorf("%s: expected jcs-number, got %v", document, err)
}
}
}

func TestVerifyEndorsementMissingFields(t *testing.T) {
cases := []Endorsement{
{Endorser: "", Endorsement: "x", Signature: "x", Timestamp: "x"},
Expand Down
58 changes: 42 additions & 16 deletions go/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,21 @@ var (
// to line feeds, decodes entities, and runs the full text normalization
// pipeline. The returned string is trimmed.
func ExtractCanonicalText(html string, opts ...Options) (string, error) {
if len(html) > maxResourceBytes || !utf8.ValidString(html) {
if len(html) > maxResourceBytes {
return "", fmt.Errorf("resource-limit-exceeded")
}
if !utf8.ValidString(html) {
return "", fmt.Errorf("parser-profile-unsupported")
}
var o Options
if len(opts) > 0 {
o = opts[0]
}
base, err := parseBaseURL(o.BaseURL)
if err != nil {
if err := validatePortableHTML(html); err != nil {
return "", err
}

if err := validatePortableHTML(html); err != nil {
base, err := parseBaseURL(o.BaseURL)
if err != nil {
return "", err
}
fragment, err := htmlpkg.ParseFragment(strings.NewReader(html), &htmlpkg.Node{Type: htmlpkg.ElementNode, Data: "div", DataAtom: atom.Div})
Expand All @@ -91,12 +93,15 @@ func ExtractCanonicalText(html string, opts ...Options) (string, error) {
}

// ExtractClaimsFromSignedSection returns claim metadata from direct child meta
// elements. If the fragment contains a top-level signed-section, that element
// supplies the children; otherwise the fragment is treated as section inner HTML.
// elements of the first signed-section anywhere in the fragment. If there is
// no signed-section, the fragment is treated as section inner HTML.
func ExtractClaimsFromSignedSection(source string) (map[string]string, error) {
if len(source) > maxResourceBytes || !utf8.ValidString(source) {
if len(source) > maxResourceBytes {
return nil, fmt.Errorf("resource-limit-exceeded")
}
if !utf8.ValidString(source) {
return nil, fmt.Errorf("parser-profile-unsupported")
}
if err := validatePortableHTML(source); err != nil {
return nil, err
}
Expand All @@ -105,13 +110,10 @@ func ExtractClaimsFromSignedSection(source string) (map[string]string, error) {
return nil, fmt.Errorf("parser-profile-unsupported: %v", err)
}
children := fragment
for _, node := range fragment {
if node.Type == htmlpkg.ElementNode && strings.EqualFold(node.Data, "signed-section") {
children = children[:0]
for child := node.FirstChild; child != nil; child = child.NextSibling {
children = append(children, child)
}
break
if section := firstSignedSection(fragment); section != nil {
children = children[:0]
for child := section.FirstChild; child != nil; child = child.NextSibling {
children = append(children, child)
}
}
claims := make(map[string]string)
Expand Down Expand Up @@ -155,6 +157,30 @@ func ExtractClaimsFromSignedSection(source string) (map[string]string, error) {
return claims, nil
}

// firstSignedSection walks the parsed tree in document order. A signed
// section nested inside another element is still the first candidate when it
// appears before a later top-level section.
func firstSignedSection(nodes []*htmlpkg.Node) *htmlpkg.Node {
for _, node := range nodes {
if section := firstSignedSectionNode(node); section != nil {
return section
}
}
return nil
}

func firstSignedSectionNode(node *htmlpkg.Node) *htmlpkg.Node {
if node.Type == htmlpkg.ElementNode && strings.EqualFold(node.Data, "signed-section") {
return node
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if section := firstSignedSectionNode(child); section != nil {
return section
}
}
return nil
}

// x/net/html repairs malformed input and does not expose parser diagnostics.
// Keep a strict source preflight so repaired trees cannot enter the portable
// profile silently.
Expand Down Expand Up @@ -413,7 +439,7 @@ func finalizeCanonicalParts(parts []string) string {
for strings.Contains(text, "\n\n") {
text = strings.ReplaceAll(text, "\n\n", "\n")
}
return strings.Trim(text, " \n")
return strings.TrimSpace(text)
}

func appendAttributeRecords(parts *[]string, elementName string, attrs map[string]string, base *whatwgurl.Url) error {
Expand Down
45 changes: 45 additions & 0 deletions go/final_hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ func TestParserPreflightRejectsCommentsAndDeclarations(t *testing.T) {
}
}

func TestExtractionRejectsInvalidUTF8(t *testing.T) {
if _, err := ExtractCanonicalText(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'})); err == nil || !strings.Contains(err.Error(), "parser-profile-unsupported") {
t.Fatalf("invalid UTF-8 extraction error = %v, want parser-profile-unsupported", err)
}
if _, err := ExtractClaimsFromSignedSection(string([]byte{'<', 'm', 'e', 't', 'a', ' ', 0xff, '>'})); err == nil || !strings.Contains(err.Error(), "parser-profile-unsupported") {
t.Fatalf("invalid UTF-8 claim extraction error = %v, want parser-profile-unsupported", err)
}
}

func TestNormalizeTextCheckedRejectsInvalidUTF8WithParserProfileError(t *testing.T) {
if _, err := NormalizeTextChecked(string([]byte{0xff})); err == nil || !strings.Contains(err.Error(), "parser-profile-unsupported") {
t.Fatalf("NormalizeTextChecked error = %v, want parser-profile-unsupported", err)
}
}

func TestSourceLimitPrecedesInvalidUTF8Classification(t *testing.T) {
if _, err := NormalizeTextChecked(strings.Repeat(string([]byte{0xff}), maxResourceBytes+1)); err == nil || !strings.Contains(err.Error(), "resource-limit-exceeded") {
t.Fatalf("oversized invalid UTF-8 normalization error = %v, want resource-limit-exceeded", err)
}
}

func TestExtractionPreflightRunsBeforeInvalidBaseURL(t *testing.T) {
if _, err := ExtractCanonicalText("<p>unclosed", Options{BaseURL: "not a URL"}); err == nil || !strings.Contains(err.Error(), "parser-profile-unsupported") {
t.Fatalf("ExtractCanonicalText error = %v, want parser-profile-unsupported before base URL handling", err)
}
}

func TestRemoteKeyResolversBoundResponseBodies(t *testing.T) {
body := strings.Repeat("x", maxRemoteKeyBytes+1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -152,3 +179,21 @@ func TestJCSRejectsNestingBeyondLimit(t *testing.T) {
t.Fatalf("JSON at nesting limit rejected: %v", err)
}
}

func TestJCSMalformedJSONPrecedesSurrogateClassification(t *testing.T) {
if _, err := CanonicalizeJSONDocument([]byte(`{"value":"\uD800`)); err == nil || !strings.Contains(err.Error(), "jcs-invalid-json") {
t.Fatalf("malformed surrogate JSON error = %v, want jcs-invalid-json", err)
}
}

func TestExtractionAppliesOutputLimitAfterFinalization(t *testing.T) {
unit := `<p href="x" src="x" alt="x" aria-label="x"></p>`
source := strings.Repeat(unit, 10000)
output, err := ExtractCanonicalText(source, Options{BaseURL: "https://example.com/"})
if err != nil {
t.Fatalf("finalized output should be within the limit: %v", err)
}
if len(output) != 1039999 {
t.Fatalf("finalized output length = %d, want 1039999", len(output))
}
}
30 changes: 30 additions & 0 deletions go/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ func (p *strictJSONParser) string() (string, error) {
p.pos += 4
if u >= 0xd800 && u <= 0xdbff {
if p.pos+6 > len(p.data) || p.data[p.pos] != '\\' || p.data[p.pos+1] != 'u' {
if !hasUnescapedQuote(p.data, p.pos) {
return "", p.fail()
}
return "", fmt.Errorf("jcs-invalid-surrogate")
}
p.pos += 2
Expand All @@ -186,6 +189,9 @@ func (p *strictJSONParser) string() (string, error) {
}
b.WriteRune(utf16.DecodeRune(rune(u), rune(lo)))
} else if u >= 0xdc00 && u <= 0xdfff {
if !hasUnescapedQuote(p.data, p.pos) {
return "", p.fail()
}
return "", fmt.Errorf("jcs-invalid-surrogate")
} else {
b.WriteRune(rune(u))
Expand All @@ -196,6 +202,25 @@ func (p *strictJSONParser) string() (string, error) {
}
return "", p.fail()
}

func hasUnescapedQuote(data []byte, start int) bool {
escaped := false
for _, c := range data[start:] {
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
return true
}
}
return false
}

func (p *strictJSONParser) number() (float64, error) {
start := p.pos
if p.pos < len(p.data) && p.data[p.pos] == '-' {
Expand Down Expand Up @@ -238,6 +263,11 @@ func (p *strictJSONParser) number() (float64, error) {
if e != nil || n != n || n > 1.7976931348623157e308 || n < -1.7976931348623157e308 {
return 0, fmt.Errorf("jcs-number")
}
// RFC 8785 erratum 7920: reject negative zero, including negative values
// whose magnitude underflows to zero during binary64 parsing.
if p.data[start] == '-' && n == 0 {
return 0, fmt.Errorf("jcs-number")
}
return n, nil
}
func (p *strictJSONParser) array() (*jsonValue, error) {
Expand Down
Loading
Loading