diff --git a/fuzz/oss-fuzz-build.sh b/fuzz/oss-fuzz-build.sh
index 71ab845af52..06db7de9373 100755
--- a/fuzz/oss-fuzz-build.sh
+++ b/fuzz/oss-fuzz-build.sh
@@ -137,6 +137,9 @@ build_teleport_fuzzers() {
compile_native_go_fuzzer $TELEPORT_PREFIX/lib/scopes \
FuzzValidateQualifiedName fuzz_validate_qualified_name
+ compile_native_go_fuzzer $TELEPORT_PREFIX/lib/appresource \
+ FuzzTokenizeNonASCII fuzz_tokenize_non_ascii
+
}
build_teleport_api_fuzzers() {
diff --git a/lib/appresource/tokenize.go b/lib/appresource/tokenize.go
new file mode 100644
index 00000000000..fdf2b8d3b3e
--- /dev/null
+++ b/lib/appresource/tokenize.go
@@ -0,0 +1,266 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// Package appresource checks whether an HTTP app request is allowed
+// by a role. Roles carry allow-only rules. A rule can match on
+// request path, HTTP method, and a where predicate over the user
+// identity. Every field is optional.
+//
+// Example role fragment:
+//
+// allow:
+// app_resources:
+// - paths:
+// - /api/v4/user/{username}
+// where: user.name == vars.username
+package appresource
+
+import (
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/gravitational/trace"
+ "golang.org/x/text/unicode/norm"
+)
+
+// maxPathLength bounds the length of a path Tokenize accepts.
+const maxPathLength = 8 << 10 // 8 KiB
+
+// legalPathPunct is the non-alphanumeric bytes allowed in a raw
+// URL path. It is RFC 3986 pchar except for ";", plus "/" and "%".
+//
+// ";" is dropped because matrix parameters and ";jsessionid" may
+// cause the matcher and the upstream app to disagree on where the
+// path ends.
+const legalPathPunct = "-._~!$&'()*+,=:@/%"
+
+// Tokenize validates an HTTP request path and splits it on a real
+// "/" into the encoded segments a role's path rules match against, so
+// an encoded slash stays inside one segment. Pass
+// [net/url.URL.EscapedPath], the encoded path sent to the upstream
+// app, not the already-decoded [net/url.URL.Path].
+//
+// Tokenize accepts a path that starts with "/", stays under 8 KiB,
+// and holds only the path characters RFC 3986 allows, except for ";".
+// Anything else has to be sent percent-encoded, and the only escapes
+// allowed are the separator %2F ("/"), the space %20 (" "), and the
+// UTF-8 bytes of non-ASCII text. Tokenize also rejects a path an
+// upstream app could read as a different path than the one a role
+// matched, such as "/a/../b" or "/files/secret." on a server that
+// trims a trailing dot.
+func Tokenize(path string) ([]string, error) {
+ if len(path) > maxPathLength {
+ return nil, trace.BadParameter("path length %d exceeds the %d byte limit", len(path), maxPathLength)
+ }
+ if !strings.HasPrefix(path, "/") {
+ return nil, trace.BadParameter("path %q must start with /", clip(path))
+ }
+ if err := validateRawBytes(path); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := validateDecoded(path); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return strings.Split(path[1:], "/"), nil
+}
+
+// validateRawBytes rejects any byte that cannot appear in a URL path
+// under RFC 3986, any invalid percent-escape, and every escape except
+// %2F ("/"), %20 (" "), or one that decodes to a non-ASCII byte.
+func validateRawBytes(path string) error {
+ for i := 0; i < len(path); i++ {
+ if !isLegalPathByte(path[i]) {
+ return trace.BadParameter("path %q contains an illegal URL byte %q", clip(path), path[i:i+1])
+ }
+ if path[i] != '%' {
+ continue
+ }
+ if i+2 >= len(path) {
+ return trace.BadParameter("path %q has a truncated percent-escape", clip(path))
+ }
+ v, err := strconv.ParseUint(path[i+1:i+3], 16, 8)
+ if err != nil {
+ return trace.BadParameter("path %q has a malformed percent-escape %q", clip(path), path[i:i+3])
+ }
+ if !isAllowedEscape(byte(v)) {
+ const msg = "path %q contains the percent-escape %q; only the encoded separator %%2F, the encoded space %%20, and non-ASCII content escapes are allowed"
+ return trace.BadParameter(msg, clip(path), path[i:i+3])
+ }
+ i += 2
+ }
+ return nil
+}
+
+// isLegalPathByte reports whether a given byte may appear in a raw
+// URL path.
+func isLegalPathByte(b byte) bool {
+ switch {
+ case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':
+ return true
+ }
+ return strings.IndexByte(legalPathPunct, b) >= 0
+}
+
+// isAllowedEscape reports whether a percent-escape decoding to b may
+// appear in a path. [validateRawBytes] accepts exactly the escapes
+// [decode] resolves, which are the separator, the space, and
+// non-ASCII content.
+func isAllowedEscape(b byte) bool {
+ return b == '/' || b == ' ' || b >= 0x80
+}
+
+// validateDecoded checks the decoded validation view of the path. It
+// rejects consecutive slashes, "." and ".." segments, and any content
+// that is not NFKC-stable graphic UTF-8.
+func validateDecoded(path string) error {
+ decoded := decode(path)
+ if strings.Contains(decoded, "//") {
+ const msg = "path %q has consecutive slashes once the encoded separator %%2F is decoded"
+ return trace.BadParameter(msg, clip(path))
+ }
+ if !utf8.ValidString(decoded) {
+ return trace.BadParameter("path %q is not valid UTF-8 once decoded", clip(path))
+ }
+ for seg := range strings.SplitSeq(decoded[1:], "/") {
+ if err := rejectDotSegment(seg); err != nil {
+ return trace.Wrap(err)
+ }
+ if err := rejectLeadingMark(seg); err != nil {
+ return trace.Wrap(err)
+ }
+ if err := rejectEdgeSpace(seg); err != nil {
+ return trace.Wrap(err)
+ }
+ if err := rejectTrailingDot(seg); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+ if !norm.NFKC.IsNormalString(decoded) {
+ return trace.BadParameter("path %q is not NFKC-normalized", clip(path))
+ }
+ for _, r := range decoded {
+ if !isGraphicRune(r) {
+ const msg = "path %q contains the disallowed character %q; only letters, marks, numbers, punctuation, symbols, and the encoded space %%20 are allowed"
+ return trace.BadParameter(msg, clip(path), string(r))
+ }
+ }
+ return nil
+}
+
+// decode returns the decoded validation view of path s, resolving
+// only valid escapes. %2F and %2f become "/", %20 a space, and a
+// non-ASCII escape its byte. The view exposes a structural byte
+// written as an escape, so "/x%2F..%2Fadmin" is rejected for the same
+// reason ".." is rejected in "/x/../admin".
+func decode(s string) string {
+ if !strings.ContainsRune(s, '%') {
+ return s
+ }
+ var b strings.Builder
+ b.Grow(len(s))
+ for i := 0; i < len(s); i++ {
+ if s[i] == '%' && i+2 < len(s) {
+ if v, err := strconv.ParseUint(s[i+1:i+3], 16, 8); err == nil && isAllowedEscape(byte(v)) {
+ b.WriteByte(byte(v))
+ i += 2
+ continue
+ }
+ }
+ b.WriteByte(s[i])
+ }
+ return b.String()
+}
+
+// rejectDotSegment rejects a segment made of only dots and spaces
+// that has at least one dot. "." and ".." are traversal segments,
+// and an upstream that strips spaces or extra dots would resolve
+// forms like ". ." or "..." the same way.
+func rejectDotSegment(seg string) error {
+ if strings.Trim(seg, ". ") == "" && strings.Contains(seg, ".") {
+ const msg = `segment %q is only dots and spaces; an upstream could resolve it as "." or ".."`
+ return trace.BadParameter(msg, clip(seg))
+ }
+ return nil
+}
+
+// rejectLeadingMark rejects a segment whose first character composes
+// onto the character before it, so "/a/%CC%87b", where %CC%87 is
+// U+0307 combining dot above, looks like "/a/b". RFC 5891 bans the
+// same form at the start of a domain label.
+func rejectLeadingMark(seg string) error {
+ if seg == "" || seg[0] < utf8.RuneSelf {
+ return nil
+ }
+ r, _ := utf8.DecodeRuneInString(seg)
+ // Some composing characters are not marks, so both checks are needed.
+ if unicode.IsMark(r) || !norm.NFKC.PropertiesString(seg).BoundaryBefore() {
+ const msg = "segment %q starts with %q, which composes onto the character before it; it must follow a base character"
+ return trace.BadParameter(msg, clip(seg), string(r))
+ }
+ return nil
+}
+
+// rejectEdgeSpace rejects a segment whose first or last rune is a
+// space. An upstream that trims the segment would see a different
+// one, so "..%20" would trim to ".." and "secret%20" to "secret".
+func rejectEdgeSpace(seg string) error {
+ if strings.HasPrefix(seg, " ") || strings.HasSuffix(seg, " ") {
+ const msg = "segment %q starts or ends with a space; a space must be between other characters"
+ return trace.BadParameter(msg, clip(seg))
+ }
+ return nil
+}
+
+// rejectTrailingDot rejects a segment whose trailing run of dots and
+// spaces contains a dot. IIS and Windows trim trailing dots and
+// spaces, so an upstream would resolve "secret." and "secret%20." as
+// "secret", a segment the matcher never saw. A leading dot stays
+// allowed because paths such as "/.well-known" depend on it.
+func rejectTrailingDot(seg string) error {
+ trimmed := strings.TrimRight(seg, ". ")
+ if strings.Contains(seg[len(trimmed):], ".") {
+ const msg = "segment %q ends with dots and spaces; an upstream could trim it to %q"
+ return trace.BadParameter(msg, clip(seg), clip(trimmed))
+ }
+ return nil
+}
+
+// isGraphicRune reports whether r is U+0020, a letter, mark, number,
+// punctuation, or symbol. U+0020 is allowed because it only reaches
+// the decoded view as %20, and [rejectEdgeSpace] keeps it off the
+// segment edges. Every other space and separator rune that
+// unicode.IsGraphic allows is excluded, along with control, format,
+// surrogate, private-use, and unassigned runes. This is the only
+// check that rejects the NFKC-stable ones among them, such as U+1680
+// ogham space mark.
+func isGraphicRune(r rune) bool {
+ return r == ' ' || unicode.IsLetter(r) || unicode.IsMark(r) ||
+ unicode.IsNumber(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)
+}
+
+// clip shortens s for use in an error message. A rejected path can be
+// kilobytes long, and the message survives into logs and audit events.
+func clip(s string) string {
+ const limit = 256
+ if len(s) <= limit {
+ return s
+ }
+ return s[:limit] + "..."
+}
diff --git a/lib/appresource/tokenize_property_test.go b/lib/appresource/tokenize_property_test.go
new file mode 100644
index 00000000000..624347d12d8
--- /dev/null
+++ b/lib/appresource/tokenize_property_test.go
@@ -0,0 +1,386 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package appresource
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+ "testing"
+ "unicode"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/unicode/norm"
+ "pgregory.net/rapid"
+)
+
+// stableMarkPairs are base characters followed by a combining mark
+// that has no pre-composed form with them, so each pair survives NFKC
+// and gives the generator a mark where a mark is allowed.
+var stableMarkPairs = []string{
+ "q̇", // U+0071 U+0307, latin q, combining dot above
+ "v́", // U+0076 U+0301, latin v, combining acute
+ "j̣", // U+006A U+0323, latin j, combining dot below
+ "α̈", // U+03B1 U+0308, greek alpha, combining diaeresis
+ "б́", // U+0431 U+0301, cyrillic be, combining acute
+ "का", // U+0915 U+093E, devanagari ka, vowel sign aa, a spacing mark
+ "ก่", // U+0E01 U+0E48, thai ko kai, mai ek
+ "あ゙", // U+3042 U+3099, hiragana a, combining voiced sound mark
+ "\u05d0\u05b7", // U+05D0 U+05B7, hebrew alef, point patah, escaped because RTL reorders the line
+ "\u0627\u064e", // U+0627 U+064E, arabic alef, fatha, escaped because RTL reorders the line
+}
+
+// safePunct is legalPathPunct without the two bytes the generator
+// places itself, "/" as the segment boundary and "%" as the start of
+// an escape.
+var safePunct = strings.NewReplacer("/", "", "%", "").Replace(legalPathPunct)
+
+const alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+// TestTokenizePropertyAcceptsWireForm asserts that every path the wire
+// generator produces is accepted. It pins over-rejection, which the
+// reject-only properties below cannot see.
+func TestTokenizePropertyAcceptsWireForm(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ _, err := Tokenize(path)
+ require.NoError(t, err, "generated wire path rejected: %q", path)
+ })
+}
+
+// TestTokenizePropertyAcceptsOnlyEscapedForm asserts that an accepted
+// path is what net/url emits for it. The reverse proxy forwards
+// EscapedPath, so a path Go would re-encode reaches the app as bytes
+// the matcher never saw. net/url decides here, not the generator, so
+// widening a byte rule without widening what net/url preserves fails
+// this property.
+func TestTokenizePropertyAcceptsOnlyEscapedForm(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ _, err := Tokenize(path)
+ require.NoError(t, err)
+ u, err := url.ParseRequestURI(path)
+ require.NoError(t, err, "accepted path does not parse: %q", path)
+ require.Equal(t, path, u.EscapedPath(), "accepted path is not its own escaped form")
+ })
+}
+
+// TestTokenizePropertyPreservesStructure asserts that tokens are the
+// input split on raw "/" and nothing else. Rejoining them reproduces
+// the input byte for byte, so no token was decoded or rewritten.
+func TestTokenizePropertyPreservesStructure(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ tokens, err := Tokenize(path)
+ require.NoError(t, err)
+ require.Equal(t, path, "/"+strings.Join(tokens, "/"))
+ require.Len(t, tokens, strings.Count(path, "/"))
+ for _, tok := range tokens {
+ require.NotContains(t, tok, "/")
+ }
+ })
+}
+
+// TestTokenizePropertyIgnoresHexCase asserts that the hex case of an
+// escape never changes the verdict, and that the tokens keep the case
+// they arrived in.
+func TestTokenizePropertyIgnoresHexCase(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ flipped := flipHexCase(path)
+ tokens, err := Tokenize(path)
+ require.NoError(t, err)
+ flippedTokens, err := Tokenize(flipped)
+ require.NoError(t, err, "hex case flip changed the verdict: %q", flipped)
+ require.Equal(t, flipped, "/"+strings.Join(flippedTokens, "/"))
+ require.Len(t, flippedTokens, len(tokens))
+ })
+}
+
+// TestTokenizePropertyRejectsRawNonASCII asserts that a raw byte at or
+// above 0x80 anywhere in an accepted path is rejected. Unicode must
+// arrive percent-encoded.
+func TestTokenizePropertyRejectsRawNonASCII(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ b := byte(rapid.IntRange(0x80, 0xff).Draw(t, "byte"))
+ mutated := insertAt(t, path, string(b))
+ _, err := Tokenize(mutated)
+ require.Error(t, err, "raw byte %#x not rejected: %q", b, mutated)
+ })
+}
+
+// TestTokenizePropertyRejectsFoldForms asserts that a known fold or
+// canonicalization form inserted anywhere in an accepted path is
+// rejected.
+func TestTokenizePropertyRejectsFoldForms(t *testing.T) {
+ forms := map[string]string{
+ "%2E (.)": "%2E",
+ "%00 (NUL)": "%00",
+ "%3F (?)": "%3F",
+ "%5C (backslash)": "%5C",
+ "%C0%AF (overlong UTF-8 for /)": "%C0%AF",
+ "%EF%BC%8F (fullwidth solidus)": "%EF%BC%8F",
+ "%E2%80%8B (zero-width space)": "%E2%80%8B",
+ "%C2%A0 (no-break space)": "%C2%A0",
+ "%E1%9A%80 (ogham space mark)": "%E1%9A%80",
+ "%CC%81 (combining acute on café)": "cafe%CC%81",
+ }
+ for name, form := range forms {
+ t.Run(name, func(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ mutated := insertAt(t, path, form)
+ _, err := Tokenize(mutated)
+ require.Error(t, err, "%s not rejected: %q", name, mutated)
+ })
+ })
+ }
+}
+
+// TestTokenizePropertyRejectsBadSegments asserts that a bad segment
+// spliced in after any "/" of an accepted path is rejected.
+func TestTokenizePropertyRejectsBadSegments(t *testing.T) {
+ segments := map[string]string{
+ "dot": ".",
+ "dot-dot": "..",
+ "empty": "",
+ "leading mark %CC%87": "%CC%87x",
+ "leading mark after %2F": "x%2F%CC%87y",
+ "encoded dot-dot between": "x%2F..%2Fy",
+ "only a space %20": "%20",
+ "leading space %20": "%20x",
+ "trailing space %20": "x%20",
+ "leading space %20 after %2F": "x%2F%20y",
+ "trailing space %20 before %2F": "x%20%2Fy",
+ "dot space dot": ".%20.",
+ "dot-dot space dot": "..%20.",
+ "only dots": "...",
+ "dots and spaces after %2F": "x%2F.%20.",
+ "trailing dot": "x.",
+ "trailing dots": "x..",
+ "trailing space dot": "x%20.",
+ "trailing dot before %2F": "x.%2Fy",
+ }
+ for name, segment := range segments {
+ t.Run(name, func(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ var slashes []int
+ for i := range len(path) {
+ if path[i] == '/' {
+ slashes = append(slashes, i)
+ }
+ }
+ n := rapid.IntRange(0, len(slashes)-1).Draw(t, "slash")
+ i := slashes[n]
+ mutated := path[:i+1] + segment + "/" + path[i+1:]
+ _, err := Tokenize(mutated)
+ require.Error(t, err, "segment %q not rejected: %q", segment, mutated)
+ })
+ })
+ }
+}
+
+// TestTokenizePropertyRejectsEdgeSpaces asserts that a %20 spliced at
+// the start or end of any part of an accepted path is rejected. An
+// upstream that trims the part would see a different part.
+func TestTokenizePropertyRejectsEdgeSpaces(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ path := wirePathGen().Draw(t, "path")
+ starts, ends := partBounds(path)
+ bounds := starts
+ if rapid.Bool().Draw(t, "trailing") {
+ bounds = ends
+ }
+ i := rapid.SampledFrom(bounds).Draw(t, "bound")
+ mutated := path[:i] + "%20" + path[i:]
+ _, err := Tokenize(mutated)
+ require.Error(t, err, "edge space not rejected: %q", mutated)
+ })
+}
+
+// wirePathGen produces a valid wire path, one or more segments with an
+// optional trailing slash.
+func wirePathGen() *rapid.Generator[string] {
+ return rapid.Custom(func(t *rapid.T) string {
+ n := rapid.IntRange(1, 4).Draw(t, "segment-count")
+ segments := make([]string, n)
+ for i := range segments {
+ segments[i] = segmentGen().Draw(t, "segment")
+ }
+ path := "/" + strings.Join(segments, "/")
+ if rapid.Bool().Draw(t, "trailing-slash") {
+ path += "/"
+ }
+ return path
+ })
+}
+
+// segmentGen joins one or more parts with the encoded separator, %2F
+// or %2f.
+func segmentGen() *rapid.Generator[string] {
+ return rapid.Custom(func(t *rapid.T) string {
+ n := rapid.IntRange(1, 3).Draw(t, "part-count")
+ parts := make([]string, n)
+ for i := range parts {
+ parts[i] = partGen().Draw(t, "part")
+ }
+ sep := rapid.SampledFrom([]string{"%2F", "%2f"}).Draw(t, "encoded-separator")
+ return strings.Join(parts, sep)
+ })
+}
+
+// partGen produces the percent-encoded content between two separators.
+// No part starts with a mark and no rune composes with its neighbor.
+// A space, emitted as %20, composes with no neighbor under NFKC, so
+// it needs no neighbor rule. The filter drops parts made of only
+// dots and spaces, parts that start or end with a space, and parts
+// whose trailing run of dots and spaces contains a dot.
+func partGen() *rapid.Generator[string] {
+ content := rapid.Custom(func(t *rapid.T) string {
+ var b strings.Builder
+ n := rapid.IntRange(1, 5).Draw(t, "rune-count")
+ for range n {
+ switch rapid.IntRange(0, 4).Draw(t, "rune-kind") {
+ case 0:
+ b.WriteByte(alnum[rapid.IntRange(0, len(alnum)-1).Draw(t, "alnum")])
+ case 1:
+ b.WriteByte(safePunct[rapid.IntRange(0, len(safePunct)-1).Draw(t, "punct")])
+ case 2:
+ b.WriteRune(safeRuneGen().Draw(t, "non-ascii"))
+ case 3:
+ b.WriteString(rapid.SampledFrom(stableMarkPairs).Draw(t, "mark-pair"))
+ case 4:
+ b.WriteByte(' ')
+ }
+ if rapid.Bool().Draw(t, "trailing-mark") {
+ b.WriteRune(freeMarkGen().Draw(t, "mark"))
+ }
+ }
+ return b.String()
+ }).Filter(func(s string) bool {
+ return strings.Trim(s, ". ") != "" &&
+ !strings.HasPrefix(s, " ") && !strings.HasSuffix(s, " ") &&
+ !strings.Contains(s[len(strings.TrimRight(s, ". ")):], ".")
+ })
+ return rapid.Custom(func(t *rapid.T) string {
+ return encodeNonASCII(t, content.Draw(t, "content"))
+ })
+}
+
+// safeRuneGen produces a non-ASCII rune that is legal anywhere in a
+// part, including first, so it excludes the marks.
+func safeRuneGen() *rapid.Generator[rune] {
+ return freeRuneGen().Filter(func(r rune) bool { return !unicode.IsMark(r) })
+}
+
+// freeMarkGen produces a mark that stands on its own, so it is legal
+// anywhere except as the first rune of a part.
+func freeMarkGen() *rapid.Generator[rune] {
+ return freeRuneGen().Filter(unicode.IsMark)
+}
+
+// freeRuneGen produces a non-ASCII rune that NFKC puts a boundary
+// before, so it never combines with whatever the generator wrote
+// previously. That covers the combining marks with a non-zero class
+// and the trailing Hangul jamo without naming either. The filter reads
+// the stdlib category tables and the normalization properties rather
+// than isGraphicRune, so the generator does not inherit the
+// validator's own view of what is allowed.
+func freeRuneGen() *rapid.Generator[rune] {
+ return rapid.Rune().Filter(func(r rune) bool {
+ if r < 0x80 || !unicode.IsGraphic(r) || unicode.IsSpace(r) {
+ return false
+ }
+ return norm.NFKC.PropertiesString(string(r)).BoundaryBefore() &&
+ norm.NFKC.IsNormalString(string(r))
+ })
+}
+
+// encodeNonASCII percent-encodes the bytes of s at or above 0x80 and
+// the space, which has no raw form in a path, and leaves the other
+// ASCII bytes raw, in the hex case the generator picks.
+func encodeNonASCII(t *rapid.T, s string) string {
+ format := "%%%02x"
+ if rapid.Bool().Draw(t, "upper-hex") {
+ format = "%%%02X"
+ }
+ var b strings.Builder
+ for i := range len(s) {
+ if s[i] < 0x80 && s[i] != ' ' {
+ b.WriteByte(s[i])
+ continue
+ }
+ fmt.Fprintf(&b, format, s[i])
+ }
+ return b.String()
+}
+
+// partBounds returns the offsets where a part starts and ends. A part
+// is bounded by a raw slash, an encoded separator, or the end of the
+// path.
+func partBounds(path string) (starts, ends []int) {
+ for i := range len(path) {
+ switch {
+ case path[i] == '/':
+ starts = append(starts, i+1)
+ if i > 0 {
+ ends = append(ends, i)
+ }
+ case path[i] == '%' && i+2 < len(path) && path[i+1] == '2' && (path[i+2] == 'F' || path[i+2] == 'f'):
+ starts = append(starts, i+3)
+ ends = append(ends, i)
+ }
+ }
+ ends = append(ends, len(path))
+ return starts, ends
+}
+
+// insertAt splices s into path at a generated offset after the leading
+// slash.
+func insertAt(t *rapid.T, path, s string) string {
+ i := rapid.IntRange(1, len(path)).Draw(t, "offset")
+ return path[:i] + s + path[i:]
+}
+
+// flipHexCase swaps the case of the hex digits of every escape in
+// path.
+func flipHexCase(path string) string {
+ out := []byte(path)
+ for i := 0; i+2 < len(out); i++ {
+ if out[i] != '%' {
+ continue
+ }
+ out[i+1] = flipByteCase(out[i+1])
+ out[i+2] = flipByteCase(out[i+2])
+ }
+ return string(out)
+}
+
+// flipByteCase swaps the case of an ASCII letter.
+func flipByteCase(b byte) byte {
+ switch {
+ case b >= 'a' && b <= 'z':
+ return b - 'a' + 'A'
+ case b >= 'A' && b <= 'Z':
+ return b - 'A' + 'a'
+ }
+ return b
+}
diff --git a/lib/appresource/tokenize_test.go b/lib/appresource/tokenize_test.go
new file mode 100644
index 00000000000..8c8f9e5aa3e
--- /dev/null
+++ b/lib/appresource/tokenize_test.go
@@ -0,0 +1,568 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package appresource
+
+import (
+ "net/url"
+ "strings"
+ "testing"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/unicode/norm"
+)
+
+// TestTokenize pins the tokenizer's accept and reject cases, including
+// the opaque encoded separator and the decode-for-validation view.
+func TestTokenize(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ want []string
+ wantErr bool
+ }{
+ {
+ name: "plain path splits on real slashes",
+ path: "/api/v4/projects",
+ want: []string{"api", "v4", "projects"},
+ },
+ {
+ name: "bare root yields a single empty token",
+ path: "/",
+ want: []string{""},
+ },
+ {
+ name: "path at the length cap is allowed",
+ path: "/" + strings.Repeat("a", maxPathLength-1),
+ want: []string{strings.Repeat("a", maxPathLength-1)},
+ },
+ {
+ name: "encoded slash stays one opaque token",
+ path: "/files/a%2Fb",
+ want: []string{"files", "a%2Fb"},
+ },
+ {
+ name: "lowercase encoded slash stays one raw token",
+ path: "/files/a%2fb",
+ want: []string{"files", "a%2fb"},
+ },
+ {
+ name: "trailing slash yields a trailing empty token",
+ path: "/files/",
+ want: []string{"files", ""},
+ },
+ {
+ name: "trailing encoded slash is allowed raw",
+ path: "/files/a%2F",
+ want: []string{"files", "a%2F"},
+ },
+ {
+ // é arrives percent-encoded and stays raw in the token.
+ name: "percent-encoded UTF-8 content is allowed raw",
+ path: "/files/caf%C3%A9.md",
+ want: []string{"files", "caf%C3%A9.md"},
+ },
+ {
+ name: "encoded space %20 stays raw in the token",
+ path: "/job/My%20Job/lastBuild",
+ want: []string{"job", "My%20Job", "lastBuild"},
+ },
+ {
+ name: "encoded space %20 in the first segment",
+ path: "/My%20Jobs/config",
+ want: []string{"My%20Jobs", "config"},
+ },
+ {
+ name: "encoded space %20 in the last segment",
+ path: "/job/last%20build",
+ want: []string{"job", "last%20build"},
+ },
+ {
+ name: "several encoded spaces %20 in one segment",
+ path: "/job/a%20b%20c",
+ want: []string{"job", "a%20b%20c"},
+ },
+ {
+ name: "consecutive encoded spaces %20%20 are allowed",
+ path: "/job/a%20%20b",
+ want: []string{"job", "a%20%20b"},
+ },
+ {
+ name: "encoded spaces %20 in several segments are allowed",
+ path: "/sites/Team%20Site/Shared%20Documents/x",
+ want: []string{"sites", "Team%20Site", "Shared%20Documents", "x"},
+ },
+ {
+ name: "interior encoded space %20 next to the encoded separator %2F",
+ path: "/files/a%20b%2Fc",
+ want: []string{"files", "a%20b%2Fc"},
+ },
+ {
+ name: "encoded space %20 next to a non-ASCII escape",
+ path: "/files/caf%C3%A9%20menu",
+ want: []string{"files", "caf%C3%A9%20menu"},
+ },
+ {
+ name: "dots and an encoded space %20 among other characters are allowed",
+ path: "/a/a.%20.b/c",
+ want: []string{"a", "a.%20.b", "c"},
+ },
+ {
+ name: "combining mark %CC%87 (U+0307) on a base character is allowed",
+ path: "/p/q%CC%87x",
+ want: []string{"p", "q%CC%87x"},
+ },
+ {
+ name: "interior dots in a segment are allowed",
+ path: "/a/1.2.3/b",
+ want: []string{"a", "1.2.3", "b"},
+ },
+ {
+ name: "a leading dot in a segment is allowed",
+ path: "/.well-known/openid-configuration",
+ want: []string{".well-known", "openid-configuration"},
+ },
+ {
+ // The segment ends with the mark riding the space, not
+ // with a space byte, so no upstream trim fires on it.
+ name: "a combining mark %CC%81 on a trailing interior space %20 is allowed",
+ path: "/p/x%20%CC%81",
+ want: []string{"p", "x%20%CC%81"},
+ },
+ {
+ name: "path over the length cap is rejected",
+ path: "/" + strings.Repeat("a", maxPathLength),
+ wantErr: true,
+ },
+ {
+ name: "double-encoded slash is rejected because %25 decodes to %",
+ path: "/files/a%252Fb",
+ wantErr: true,
+ },
+ {
+ name: "an ASCII escape %40 (@) is rejected",
+ path: "/files/a%40b",
+ wantErr: true,
+ },
+ {
+ name: "an encoded dot %2E (.) is rejected",
+ path: "/files/a%2Eb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded hash %23 (#) is rejected",
+ path: "/files/a%23b",
+ wantErr: true,
+ },
+ {
+ name: "an encoded percent %25 (%) is rejected",
+ path: "/files/a%25b",
+ wantErr: true,
+ },
+ {
+ name: "an encoded question mark %3F (?) is rejected",
+ path: "/files/a%3Fb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded NUL %00 is rejected",
+ path: "/files/a%00b",
+ wantErr: true,
+ },
+ {
+ name: "an encoded double quote %22 (\") is rejected",
+ path: "/files/a%22b",
+ wantErr: true,
+ },
+ {
+ name: "an encoded less-than %3C (<) is rejected",
+ path: "/files/a%3Cb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded greater-than %3E (>) is rejected",
+ path: "/files/a%3Eb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded opening bracket %5B ([) is rejected",
+ path: "/files/a%5Bb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded closing bracket %5D (]) is rejected",
+ path: "/files/a%5Db",
+ wantErr: true,
+ },
+ {
+ name: "an encoded backslash %5C (\\) is rejected",
+ path: "/files/a%5Cb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded caret %5E (^) is rejected",
+ path: "/files/a%5Eb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded backtick %60 (`) is rejected",
+ path: "/files/a%60b",
+ wantErr: true,
+ },
+ {
+ name: "an encoded opening brace %7B ({) is rejected",
+ path: "/files/a%7Bb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded pipe %7C (|) is rejected",
+ path: "/files/a%7Cb",
+ wantErr: true,
+ },
+ {
+ name: "an encoded closing brace %7D (}) is rejected",
+ path: "/files/a%7Db",
+ wantErr: true,
+ },
+ {
+ name: "a segment of only the encoded space %20 is rejected",
+ path: "/a/%20/b",
+ wantErr: true,
+ },
+ {
+ name: "a leading encoded space %20 in a segment is rejected",
+ path: "/a/%20x",
+ wantErr: true,
+ },
+ {
+ name: "a trailing encoded space %20 in a segment is rejected",
+ path: "/a/x%20",
+ wantErr: true,
+ },
+ {
+ // The segment decodes to ".. ", which trims to "..".
+ name: "a dot-dot with a trailing encoded space %20 is rejected",
+ path: "/a/..%20/b",
+ wantErr: true,
+ },
+ {
+ // The segment decodes to ". ", which trims to ".".
+ name: "a dot with a trailing encoded space %20 is rejected",
+ path: "/a/.%20/b",
+ wantErr: true,
+ },
+ {
+ name: "a dot-dot with a leading encoded space %20 is rejected",
+ path: "/a/%20../b",
+ wantErr: true,
+ },
+ {
+ name: "a leading encoded space %20 after an encoded slash %2F is rejected",
+ path: "/p/a%2F%20b/c",
+ wantErr: true,
+ },
+ {
+ name: "a trailing encoded space %20 before an encoded slash %2F is rejected",
+ path: "/p/b%20%2Fc/d",
+ wantErr: true,
+ },
+ {
+ // The segment starts with a space that carries the mark.
+ name: "a combining mark %CC%81 (U+0301) on a leading encoded space %20 is rejected",
+ path: "/p/%20%CC%81x",
+ wantErr: true,
+ },
+ {
+ // The segment decodes to ". .", stripped of spaces "..".
+ name: "an encoded space %20 between dots is rejected",
+ path: "/a/.%20./b",
+ wantErr: true,
+ },
+ {
+ // The dot-segment rule fires; the segment also violates
+ // the edge-space rule.
+ name: "a segment of alternating %20 and dots is rejected",
+ path: "/a/%20.%20.%20/b",
+ wantErr: true,
+ },
+ {
+ name: "a dot-dot with %20 and a trailing dot is rejected",
+ path: "/a/..%20./b",
+ wantErr: true,
+ },
+ {
+ name: "dots separated by encoded spaces %20 are rejected",
+ path: "/a/.%20.%20./b",
+ wantErr: true,
+ },
+ {
+ name: "dots and %20 between encoded slashes %2F are rejected",
+ path: "/p/a%2F.%20.%2Fb",
+ wantErr: true,
+ },
+ {
+ name: "a segment of only dots is rejected",
+ path: "/a/.../b",
+ wantErr: true,
+ },
+ {
+ name: "a trailing dot in a segment is rejected",
+ path: "/files/secret./x",
+ wantErr: true,
+ },
+ {
+ name: "trailing dots in a segment are rejected",
+ path: "/files/secret../x",
+ wantErr: true,
+ },
+ {
+ name: "a trailing encoded space %20 and dot in a segment are rejected",
+ path: "/files/secret%20./x",
+ wantErr: true,
+ },
+ {
+ name: "a trailing dot before an encoded slash %2F is rejected",
+ path: "/p/a.%2Fb",
+ wantErr: true,
+ },
+ {
+ name: "a truncated escape is rejected",
+ path: "/files/a%2",
+ wantErr: true,
+ },
+ {
+ name: "a malformed escape with non-hex digits is rejected",
+ path: "/files/a%G1b",
+ wantErr: true,
+ },
+ {
+ name: "a lone percent is rejected",
+ path: "/files/a%",
+ wantErr: true,
+ },
+ {
+ name: "a dot-dot between encoded slashes is rejected",
+ path: "/a%2F..%2Fadmin",
+ wantErr: true,
+ },
+ {
+ name: "an empty inner part in encoded slashes is rejected",
+ path: "/a%2F%2Fb",
+ wantErr: true,
+ },
+ {
+ name: "a raw dot-dot segment is rejected",
+ path: "/api/v4/../secret",
+ wantErr: true,
+ },
+ {
+ name: "a raw single-dot segment is rejected",
+ path: "/api/./v4",
+ wantErr: true,
+ },
+ {
+ name: "a segment starting with the conjoining jamo %E1%86%A8 (U+11A8) is rejected",
+ path: "/%EA%B0%80/%E1%86%A8",
+ wantErr: true,
+ },
+ {
+ name: "a segment starting with the combining mark %CC%87 is rejected",
+ path: "/p/%CC%87x",
+ wantErr: true,
+ },
+ {
+ name: "a combining mark after an encoded slash %2F is rejected",
+ path: "/p/a%2F%CC%87x",
+ wantErr: true,
+ },
+ {
+ name: "consecutive slashes are rejected",
+ path: "/api//v4",
+ wantErr: true,
+ },
+ {
+ name: "a path without a leading slash is rejected",
+ path: "api/v4",
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Tokenize(tt.path)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+// TestTokenizeByteRules pins the RFC 3986 path-character rules.
+// Pchar except for ";", plus "/" and "%", are allowed. Anything else
+// in the raw path is rejected.
+func TestTokenizeByteRules(t *testing.T) {
+ allow := []string{
+ "/api/v4/projects",
+ "/api/@@@", // "@" is a pchar
+ "/api/(group)/sub.tree", // sub-delims and unreserved
+ "/api/a:b/c,d/e=f/gh", // ":" and sub-delims
+ "/api/a-b_c~d!$&'()*+,=", // the unreserved and sub-delim set, except for ";"
+ "/api/v4/projects/group%2Frepo/x", // the encoded separator, allowed
+ }
+ for _, p := range allow {
+ t.Run("allow "+p, func(t *testing.T) {
+ _, err := Tokenize(p)
+ require.NoError(t, err)
+ })
+ }
+
+ reject := []string{
+ "/api/a b", // raw space, allowed only encoded as %20
+ "/api/a\"b", // double quote
+ "/api/ab", // angle bracket
+ "/api/a{b}", // braces
+ "/api/a|b", // pipe
+ "/api/a^b", // caret
+ "/api/a`b", // backtick
+ "/api/a\\b", // backslash
+ "/api/a[b]", // square brackets
+ "/api/a#b", // fragment delimiter
+ "/api/a?b", // query delimiter
+ "/api/café", // raw non-ASCII, must be percent-encoded
+ "/api/a;b/c", // semicolon, the matrix-parameter / jsessionid vector
+ }
+ for _, p := range reject {
+ t.Run("reject "+p, func(t *testing.T) {
+ _, err := Tokenize(p)
+ require.Error(t, err)
+ })
+ }
+}
+
+// TestNonASCIIFold pins the fold and homoglyph cases for the
+// non-ASCII path pipeline. Rejected entries are forms that could
+// resolve to a different segment once an upstream normalizes them.
+func TestNonASCIIFold(t *testing.T) {
+ allow := map[string]string{
+ "precomposed accent (café.md)": "/files/caf%C3%A9.md",
+ "CJK han character": "/files/%E6%97%A5.txt",
+ "cyrillic letter": "/u/%D0%B4",
+ "emoji is a symbol": "/r/%F0%9F%98%80",
+ "accent next to encoded slash": "/p/caf%C3%A9%2Fx",
+ }
+ for name, path := range allow {
+ t.Run("allow "+name, func(t *testing.T) {
+ _, err := Tokenize(path)
+ require.NoError(t, err)
+ })
+ }
+
+ reject := map[string]struct {
+ path string
+ // errContains pins which rule must reject inputs that more
+ // than one layer would catch. When empty, any error passes.
+ errContains string
+ }{
+ "raw non-ASCII byte": {path: "/files/caf\xc3\xa9"},
+ "overlong UTF-8 of slash": {path: "/p/a%C0%AFb"},
+ "lone continuation byte": {path: "/p/%A9"},
+ "truncated two-byte sequence": {path: "/p/%C3"},
+ "fullwidth solidus folds to /": {path: "/p/a%EF%BC%8Fb"},
+ "fullwidth A folds to A": {path: "/p/%EF%BC%A1dmin"},
+ "fullwidth lowercase a": {path: "/p/%EF%BD%81dmin"},
+ "zero-width space is format": {path: "/p/a%E2%80%8Bb"},
+ "bidi override is format": {path: "/p/a%E2%80%AEb"},
+ "decomposed e plus accent": {path: "/p/cafe%CC%81"},
+ "ligature fi folds to fi": {path: "/p/o%EF%AC%81ce"},
+ "non-breaking space folds": {path: "/p/a%C2%A0b", errContains: "not NFKC-normalized"},
+ "en quad %E2%80%80 (U+2000) folds to space": {path: "/p/a%E2%80%80b", errContains: "not NFKC-normalized"},
+ "ideographic space %E3%80%80 (U+3000) folds": {path: "/p/a%E3%80%80b", errContains: "not NFKC-normalized"},
+ "ogham space mark %E1%9A%80 (U+1680) is a separator": {path: "/p/a%E1%9A%80b", errContains: "disallowed character"},
+ "line separator %E2%80%A8 (U+2028) is a separator": {path: "/p/a%E2%80%A8b", errContains: "disallowed character"},
+ "conjoining jamo %E1%86%A8 (U+11A8) composes": {path: "/p/%EA%B0%80%E1%86%A8", errContains: "not NFKC-normalized"},
+ "conjoining jamo starting a segment": {path: "/%EA%B0%80/%E1%86%A8", errContains: "composes onto the character before it"},
+ }
+ for name, tt := range reject {
+ t.Run("reject "+name, func(t *testing.T) {
+ _, err := Tokenize(tt.path)
+ require.Error(t, err)
+ if tt.errContains != "" {
+ require.ErrorContains(t, err, tt.errContains)
+ }
+ })
+ }
+}
+
+// FuzzTokenizeNonASCII checks that every accepted path decodes per
+// segment to valid, NFKC-stable UTF-8. Corpus seeds cover the known
+// fold and homoglyph bypasses. The fuzzer explores around them.
+func FuzzTokenizeNonASCII(f *testing.F) {
+ for _, seed := range []string{
+ "/files/caf%C3%A9.md", "/p/a%EF%BC%8Fb", "/p/%EF%BC%A1dmin",
+ "/p/a%C0%AFb", "/p/a%E2%80%8Bb", "/p/cafe%CC%81", "/api/v4/x%2Fy",
+ "/job/My%20Job/lastBuild", "/p/%20%CC%81x", "/p/a%C2%A0b",
+ "/a/..%20/b", "/a/%20/b", "/a/.%20./b", "/a/.../b",
+ "/%EA%B0%80/%E1%86%A8",
+ "/files/secret./x", "/files/secret%20./x",
+ } {
+ f.Add(seed)
+ }
+ f.Fuzz(func(t *testing.T, path string) {
+ tokens, err := Tokenize(path)
+ if err != nil {
+ return
+ }
+ for _, tok := range tokens {
+ content := decode(tok)
+ valid := utf8.ValidString(content)
+ require.True(t, valid, "accepted token %q decodes to invalid UTF-8", tok)
+ normal := norm.NFKC.IsNormalString(content)
+ require.True(t, normal, "accepted token %q is not NFKC-stable; a fold bypass slipped through", tok)
+ for _, part := range strings.Split(content, "/") {
+ if part == "" {
+ continue
+ }
+ r, _ := utf8.DecodeRuneInString(part)
+ require.False(t, unicode.IsMark(r), "accepted token %q has a part starting with the combining mark %q", tok, string(r))
+ require.False(t, strings.HasPrefix(part, " "), "accepted token %q has a part starting with a space", tok)
+ require.False(t, strings.HasSuffix(part, " "), "accepted token %q has a part ending with a space", tok)
+ dotSpaces := strings.Trim(part, ". ") == "" && strings.Contains(part, ".")
+ require.False(t, dotSpaces, "accepted token %q has a part of only dots and spaces", tok)
+ tail := part[len(strings.TrimRight(part, ". ")):]
+ require.NotContains(t, tail, ".", "accepted token %q has a part ending with dots and spaces", tok)
+ }
+ }
+ // An accepted token contains no raw non-ASCII bytes.
+ for _, tok := range tokens {
+ for i := range len(tok) {
+ require.Less(t, tok[i], byte(0x80), "accepted token %q has a raw non-ASCII byte", tok)
+ }
+ }
+ // Rejoining path segments roundtrips cleanly.
+ require.Equal(t, path, "/"+strings.Join(tokens, "/"))
+ // An accepted path is its own escaped form.
+ u, err := url.ParseRequestURI(path)
+ require.NoError(t, err, "accepted path does not parse: %q", path)
+ require.Equal(t, path, u.EscapedPath(), "accepted path is not its own escaped form")
+ })
+}