|
Proposed improvements based on CHICKEN uri-generic library
Ivan Raikov
(24 Jun 2026 19:00 UTC)
|
|
Re: Proposed improvements based on CHICKEN uri-generic library D Guthrie (02 Aug 2026 16:07 UTC)
|
|
Re: Proposed improvements based on CHICKEN uri-generic library
Ivan Raikov
(02 Aug 2026 16:52 UTC)
|
|
Re: Proposed improvements based on CHICKEN uri-generic library
Ivan Raikov
(08 Aug 2026 22:41 UTC)
|
|
Re: Proposed improvements based on CHICKEN uri-generic library
D Guthrie
(10 Aug 2026 12:22 UTC)
|
|
Re: Proposed improvements based on CHICKEN uri-generic library
Per Bothner
(10 Aug 2026 14:42 UTC)
|
Hi, I'll reply inline.
I noted your email a while ago (thanks!) but I didn't write back as I wanted to attempt to address the UTF-8 vulnerabilities you describe…
> On 24 Jun 2026, at 19:59, Ivan Raikov <xxxxxx@gmail.com> wrote:
>
> Hello,
>
> I am one of the co-authors of the CHICKEN Scheme library `uri-generic` along with Peter Bex.
>
> Based on our experiences with uri-generic, I can make the following recommendations for SRFI 275:
>
> 1. Percent-encoding and Unicode correctness
>
> These are perhaps the most important. They address a class of security vulnerability (path confusion via encoding exploits) and a class of correctness bug (wrong byte output for non-ASCII characters).
>
> 1.1 UTF-8 encoding of non-ASCII characters must use all four byte-length forms
>
> The SRFI must state that `uri-encode-string` / `iri-encode-string` encode non-ASCII characters as their UTF-8 byte sequence, each byte separately percent-encoded, covering all four RFC 3629 sequence lengths:
>
> Code point range | UTF-8 bytes | Example character | Encoded form
> ---------------------|-------------|-------------------|----------------------
> U+0000 - U+007F | 1 | space U+0020 | %20
> U+0080 - U+07FF | 2 | é U+00E9 | %C3%A9
> U+0800 - U+FFFF | 3 | ♂ U+2642 | %E2%99%82
> U+10000 - U+10FFFF | 4 | 😎 U+1F60E | %F0%9F%98%8E
>
> The following should be a required test vector in the "URI/IRI escape normalisation"
> test suite, as it exercises all four byte lengths in a single call:
>
> (uri-encode-string "a béc♂d😎e")
> ; => "a%20b%C3%A9c%E2%99%82d%F0%9F%98%8Ee"
>
> Additionally, two validity constraints must be stated as errors:
>
> - UTF-16 surrogate code points (U+D800 - U+DFFF) must be rejected; they are not valid Unicode scalar values and cannot appear in valid UTF-8.
> - Code points above U+10FFFF must be rejected.
>
> 1.2 Overlong UTF-8 encodings must be rejected
>
> An overlong encoding uses more bytes than the minimum necessary for a code point.
> For example, `%C0%AF` is a two-byte encoding of `/` (U+002F, minimum: one byte). A
> decoder that accepts overlong forms can be bypassed: an attacker encodes a reserved character such as `/`, `:`, or `@` in overlong form to circumvent path-based access controls or authority parsing.
>
> The SRFI must require that `uri-decode-string` / `iri-decode-string` raise an error on
> overlong encodings. The detection rule follows directly from RFC 3629 Sec. 3: after
> reassembling the code point, check that the byte count equals the minimum for that
> code point's range.
>
> 1.3 Malformed UTF-8 sequences must be rejected
>
> Beyond overlong encodings, the following malformed inputs must also raise errors:
>
> ;; Overlong 2-byte encoding of U+0020 (space)
> "a%C0%A0b"
>
> ;; Overlong 3-byte encoding of U+0020
> "a%E0%80%A0b"
>
> ;; Overlong 4-byte encoding of U+0020
> "a%E0%80%80%A0b"
>
> ;; Overlong 3-byte encoding of U+00E9 (é)
> "a%E0%80%A9b"
>
> ;; Incomplete 2-byte sequence (missing continuation byte)
> "a%C0b"
>
> ;; Incomplete 3-byte sequence (missing third byte for ♂ U+2642)
> "a%E2%99"
>
> ;; Incomplete 4-byte sequence (missing fourth byte for 😎 U+1F60E)
> "a%F0%9F%98"
>
> ;; Non-encoded character where a continuation byte is expected
> "a%F0%9F%98x"
>
> ;; UTF-8 continuation byte without a leading byte
> "a%A9"
>
> These nine inputs should be added as required error test vectors in the test suite.
>
> 1.4 Multi-byte decode must be atomic with respect to the character-set argument
>
> `uri-decode-string` accepts an optional character set specifying which characters to
> decode. When a multi-byte percent-encoded sequence decodes to a character that is
> not in the target character set, all bytes of that sequence must be left encoded.
> A decoder that partially decodes a multi-byte sequence (decoding some `%XX` bytes but not others) corrupts the string irreversibly.
>
> The following should be a required test case:
>
> ;; Decode everything except ♂ (U+2642, encoded as %E2%99%82):
> (uri-decode-string "a%20b%C3%A9c%E2%99%82d%F0%9F%98%8Ee"
> (char-set-complement (char-set #\♂)))
> ; => "a béc%E2%99%82d😎e"
> ;; ^^^^^^^^^^^
> ;; All three bytes of ♂ are preserved; all other sequences are decoded.
I believe these are all rejected with the R6RS UTF-8 processing procedures (the default behaviour is to substitute the replacement character but it can be made to raise an error). I've rewritten the parser as recursive descent (previously I depended on chibi-parse) and it always invokes the R6RS UTF-8 conversion procedures on sequences of percent-encodings.
I will add these as test cases and flesh out the test suite some more. Thanks for those!
> 1.5 Round-trip invariant and character-set exports
>
> The SRFI should state the round-trip invariant explicitly:
>
> - `(string->uri (uri->string u))` produces a URI equivalent to `u`.
> - `(uri-encode-string s)` treats `%` as a literal character to encode; it does not
> detect already-encoded sequences. Consequently `(uri-encode-string "foo%20bar")`
> yields `"foo%2520bar"`, not `"foo%20bar"`.
>
> The following character sets should be exported by the library, as they are
> needed by applications that perform custom encoding, e.g. encoding only a subset of
> reserved characters:
>
> Name | Contents
> -------------------------|-----------------------------------
> char-set:gen-delims | : / ? # [ ] @
> char-set:sub-delims | ! $ & ' ( ) * + , ; =
> char-set:uri-reserved | union of the above two
> char-set:uri-unreserved | A-Z a-z 0-9 - . _ ~
I had a think about this, here's what I've come up with so far:
- A generic `encode-string' procedure which works like yours.
- Additional parsers which in contrast interpret percent encodings, but similarly escape characters disallowed in that component.
I haven't modified the setters yet to use the latter parsers, but these seem to compose quite well and there wouldn't be a danger of setting a component to characters which are disallowed.
For character sets I export those you mention, and the equivalent for IRIs.
> 2. Test suite completeness
>
> 2.1 RFC 3986 5.4.2 abnormal resolution cases must be normative test vectors
>
> The SRFI's own rationale states: "The most divergent behaviour between widespread
> implementations of URIs and IRIs has been with respect to normalisation of relative
> references." The most direct remedy is to include the RFC's own 5.4.2 abnormal
> examples as normative required test vectors, since these are exactly the cases
> implementations have historically disagreed on.
>
> Using the base URI `http://a/b/c/d;p?q`:
>
> ;; Over-deep ".." traversal is clamped at the root, not an error:
> (base "../../../g" => "http://a/g")
> (base "../../../../g" => "http://a/g")
> (base "../../../.." => "http://a/")
> (base "../../../../" => "http://a/")
>
> ;; Dot segments in absolute paths are removed:
> (base "/./g" => "http://a/g")
> (base "/../g" => "http://a/g")
>
> ;; These are NOT dot-segments; literal names, not traversal:
> (base "g.." => "http://a/b/c/g..")
> (base "..g" => "http://a/b/c/..g")
>
> ;; Dots do not affect query or fragment:
> (base "g?y/./x" => "http://a/b/c/g?y/./x")
> (base "g?y/../x" => "http://a/b/c/g?y/../x")
> (base "g#s/./x" => "http://a/b/c/g#s/./x")
> (base "g#s/../x" => "http://a/b/c/g#s/../x")
>
> 2.2 IPv6 address literals: required positive and negative test cases
>
> The SRFI test suite should include both positive and negative IPv6 cases.
> Implementations have historically differed on which compressed forms they accept
> and on whether malformed literals raise errors or silently misparse.
>
> Positive cases that must be accepted (covering the `::` compression forms that are
> commonly under-tested):
>
> "http://[::]/" ; all-zeros unspecified address
> "http://[::1]/" ; loopback
> "http://[1::]/" ; trailing ::
> "http://[2001:db8::]/" ; trailing :: with prefix
> "http://[::2001:db8]/" ; leading ::
> "http://[::ffff:192.0.2.1]/" ; IPv4-mapped
> "http://[64:ff9b::192.0.2.1]/" ; IPv4-translated (RFC 6052)
> "http://[2001:db8::1]:8080/path?query#fragment" ; with port, path, query, fragment
>
> Negative cases that must be rejected (raise an error or return `#f`):
>
> "http://[2001:db8:::1]/" ; more than one :: compression marker
> "http://[2001:db8:a:b:c:d:e:f:1]/" ; nine groups (maximum is eight)
> "http://[2001:db8:a:b:c:d:e]/" ; seven groups with no :: (too few)
> "http://[2001:db8::fffff]/" ; group exceeds four hex digits
> "http://[2001:00db8::0001]/" ; same; leading zeros push group to five digits
> "http://[2001:db8::192.0.2]/" ; incomplete IPv4 suffix (three octets, not four)
> "http://[2001:db8:a::b::c]/" ; two separate :: markers
> "http://[2001:db8]/" ; only two groups, no ::
> "http://[2001:db8" ; unclosed bracket
>
> Malformed IP literals should raise an error rather than silently returning `#f`, since
> an unclosed bracket or structurally invalid address indicates a programming error in
> the caller, not a legitimate alternative form.
>
> 2.3 A percent-encoded slash in a `..` segment does NOT constitute traversal
>
> The relative-reference resolver must treat only the literal strings `"."` and `".."`
> as dot-segments. A segment such as `..%2f` contains a percent-encoded `/` and is
> therefore an opaque name, not a traversal instruction:
>
> ;; ..%2f is NOT the same as ../
> (uri-relative-to (uri-reference "..%2f")
> (uri-reference "http://a/b/c/d;p?q"))
> ; => "http://a/b/c/..%2f" ; NOT "http://a/b/"
>
> An implementation that normalizes `..%2f` as upward traversal has a path-confusion
> vulnerability: an attacker can traverse upward while the string representation appears
> not to contain `..`.
>
> 2.4 Empty path segments must be preserved
>
> RFC 3986 3.3 treats consecutive slashes as producing empty segments, and those empty segments are meaningful because they are counted
> during `..` traversal. This has been a source of multiple bugs. The following cases must be accepted and must round-trip exactly:
>
> ;; Parsing:
> (uri-path (uri-reference "//foo//bar")) ; => (/ "" "bar")
> (uri-path (uri-reference "//foo///bar")) ; => (/ "" "" "bar")
> (uri-path (uri-reference "/foo//bar")) ; => (/ "foo" "" "bar")
> (uri-path (uri-reference "foo//bar")) ; => ("foo" "" "bar")
>
> ;; Resolution: empty segments count as segments for ".." traversal:
> (uri-relative-to (uri-reference "../../../..")
> (uri-reference "http://a//b//c"))
> ; => "http://a/"
>
> 2.5 Relative paths whose first segment contains a colon require `./` prefix
>
> RFC 3986 4.2 states that a relative-reference path whose first segment contains `:`
> must be prefixed with `./` to prevent misinterpretation as a scheme. This rule applies
> on serialization and must be reflected in the test suite.
>
> Tag URIs (RFC 4151) are the most common real-world case:
>
> (uri->string (uri-reference "tag:xxxxxx@hpl.hp.com,2001:web/externalHome"))
> ; => "tag:./xxxxxx@hpl.hp.com,2001:web/externalHome"
> ;; ^^
> ;; "./" prefix inserted because first path segment contains ":"
>
> (uri->string (uri-reference "tag:yaml.org,2002:int"))
> ; => "tag:./yaml.org,2002:int"
I believe the reference implementation is already compliant, but these will be added to the test suite and I will mention them in the next SRFI spec revision.
> 3. Missing procedures
>
> 3.1 `uri-relative-from` (inverse of `uri-relative-to`)
>
> The SRFI specifies `uri-relative-to` (resolve a relative reference against a base) but
> not its inverse. `uri-relative-from` computes the shortest relative reference from one
> absolute URI to another:
>
> (uri->string
> (uri-relative-from (uri-reference "http://example.com/Root/sub1/name2#frag")
> (uri-reference "http://example.com/Root/sub2/name2#frag")))
> ; => "../sub1/name2#frag"
>
> This operation is needed whenever a program serializes a document that contains links. Without it, every IRI-aware Scheme library must independently implement a non-trivial algorithm, and divergences could accumulate.
>
> The procedure satisfies a round-trip property that can serve as a normative requirement:
>
> (uri-relative-to (uri-relative-from T B) B) = T
>
> for any absolute URIs T and B that share the same scheme and authority.
>
> Suggested test vectors (base = `"http://a/b/c/d;p?q"`):
>
> (uri-relative-from "http://a/b/c/e" base) ; => "e"
> (uri-relative-from "http://a/b/c/" base) ; => "."
> (uri-relative-from "http://a/b/e" base) ; => "../e"
> (uri-relative-from "http://a/b/" base) ; => ".."
> (uri-relative-from "http://a/" base) ; => "../.."
> (uri-relative-from "http://a" base) ; => "//a" (no relative form)
> (uri-relative-from "http://b/c" base) ; => "//b/c"
> (uri-relative-from "ftp://a/b/c/d;p?q" base) ; => "ftp://a/b/c/d;p?q"
Could you give me an idea of how complex it is to implement this and what kind of test cases there are circulating? Implementing the normalisation procedures was frankly fraught, given a lack of official test cases (unlike for reference resolution). I'm concerned this will just make the proposal larger and more difficult to implement - it is already fairly extensive.
> 3.2 `uri-path-absolute?` and `uri-path-relative?`
>
> These predicates test whether the path component of a URI begins with `/`. They are
> frequently needed when validating that an authority-bearing URI has an absolute path
> (RFC 3986 S. 3, `hier-part` production) and when deciding how to construct relative
> references. uri-generic exports them.
>
> (uri-path-absolute? (uri-reference "http://foo/bar")) ; => #t
> (uri-path-absolute? (uri-reference "http://foo")) ; => #f (empty path)
> (uri-path-absolute? (uri-reference "/bar")) ; => #t
> (uri-path-absolute? (uri-reference "bar")) ; => #f
> (uri-path-relative? (uri-reference "http://foo")) ; => #t
>
> The subtle case is `"http://foo"` (no trailing slash): its path is empty, which is *relative* (does not begin with `/`). This differs from `"http://foo/"` whose path is `"/"` (absolute). This distinction matters for resolution and is a common source of confusion.
There's a separate internal path library which implements these. I've been fleshing it out to make segments explicit, in response to Peter Bex's suggestions and these procedures should be defined in the next revision.
Here we are:
https://codeberg.org/dguthrie/scheme-iri/src/branch/srfi-275bis/iri/internal/path.chezscheme.sls
The path structure is useful in itself so I'm likely going to include it as a utility library within the specification, something like `(srfi 275 path)'
>
> 4. API Clarifications
>
> 4.1 Authority: expose username and password as separate accessors
>
> RFC 3986 3.2.1 specifies that the first `:` in the userinfo sub-component separates
> a username from scheme-specific credential information (conventionally a password).
> The uri-generic implementation splits the userinfo at parse time and expose `uri-username` and `uri-password` as separate accessors.
>
> Thus the SRFI should either:
>
> a. Adopt this split and provide `uri-username`, `uri-password` (and IRI equivalents),
> with the split rule stated normatively: everything before the first `:` is the
> username, everything after (possibly including further colons) is the password; or
>
> b. Expose only a single `uri-userinfo` accessor, but explicitly state that the split
> interpretation is available and describe how to perform it correctly.
>
> Tricky cases that any implementation must handle correctly:
>
> (uri-username (uri-reference "//foo:bar:xxxxxx@host")) ; => "foo"
> (uri-password (uri-reference "//foo:bar:xxxxxx@host")) ; => "bar:qux"
> (uri-username (uri-reference "//@host")) ; => ""
> (uri-password (uri-reference "//@host")) ; => #f
> (uri-username (uri-reference "//xxxxxx@host")) ; => "foo"
> (uri-password (uri-reference "//xxxxxx@host")) ; => #f
> ;; %3A is a percent-encoded ":", NOT a userinfo separator:
> (uri-username (uri-reference "//foo%3Abar:xxxxxx@host")) ; => "foo%3Abar"
> (uri-password (uri-reference "//foo%3Abar:xxxxxx@host")) ; => "qux"
I'll have to think about this one. The proposal has no support for this at all currently.
> 4.2 IPv6 host literal tracking
>
> When serializing a URI back to a string, the authority component must bracket IPv6
> addresses with `[` and `]`. Implementations therefore need to track, per URI, whether
> the host is an IPv6 (or IPvFuture) literal. The authority record should provide a
> predicate or host-type accessor for this, rather than requiring callers to inspect the
> host string for `:`.
Is this really necessary? I don't have a separate record type for IP addresses, and my assumption is that if an URI has parsed correctly, then any IPv6 or IPvFuture addresses will be bracketed as they are read verbatim including the brackets. I did originally rig up an IPv6 address record type and it was frankly horrible to work with.
> 4.3 Parse modes: lenient (returns #f) vs. strict (error-raising)
>
> Consistent with RFC 3986, in uri-generic `uri-reference` returns `#f` for syntactically invalid input (lenient mode, suitable for testing or tolerant processing). A separate `absolute-uri` procedure raises an error on invalid input, including the two cases specified by RFC 3986 4.3:
>
> - No scheme present.
> - Fragment component present (absolute URIs do not permit fragments).
>
> Both modes are exercised in the uri-generic test suite and are necessary in practice: lenient parsing for filtering candidate strings, strict parsing when a valid absolute URI is
> required by contract.
I may be missing something but I don't really understand what you mean by 'consistent with RFC 3986' in the sense of error-handling, could you point me to the section(s) you're referring to?
In the process of rigging up the recursive-descent parser to address the UTF-8 octet vulnerabilities you described I added distinct parsers for absolute and non-absolute URIs, if that's any help. There isn't a separation of parsers for relative and non-relative, it elicits the relevant record type depending on how the parse proceeds.
Thanks,
Duncan