diff --git a/cmd/atenet/internal/router/extproc_in_test.go b/cmd/atenet/internal/router/extproc_in_test.go index bbb7a8f9e..31b61a04a 100644 --- a/cmd/atenet/internal/router/extproc_in_test.go +++ b/cmd/atenet/internal/router/extproc_in_test.go @@ -151,6 +151,12 @@ func TestParseActorRef(t *testing.T) { want: resources.ActorRef{Atespace: "team-a", Name: "my-actor"}, wantErr: false, }, + { + name: "mixed-case host", + host: "My-Actor.Team-A.actors.resources.substrate.ate.dev", + want: resources.ActorRef{Atespace: "team-a", Name: "my-actor"}, + wantErr: false, + }, { name: "missing atespace label", host: "my-actor.actors.resources.substrate.ate.dev", diff --git a/internal/resources/actorref.go b/internal/resources/actorref.go index 7d287ad8a..15362a825 100644 --- a/internal/resources/actorref.go +++ b/internal/resources/actorref.go @@ -71,9 +71,31 @@ func ActorRefFromActor(a *ateapipb.Actor) ActorRef { } } +// lowerASCII folds A-Z and leaves every other byte alone. Resource names are +// DNS-1123 labels, so ASCII is the whole alphabet here, and strings.ToLower +// would additionally fold characters outside it onto ASCII letters (the Kelvin +// sign U+212A onto "k", U+017F onto "s"), letting a non-ASCII host reach an +// actor under a spelling that is not its name. +func lowerASCII(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + // ParseActorDNSName parses a DNS name for a given actor. +// +// The name is folded to lower case first. DNS lookups are case-insensitive +// (RFC 4343), so a client that resolved "MyActor.MySpace." reaches us +// with that spelling preserved in the Host header, while actor and atespace +// names are always lower case. Folding keeps the request addressed to the same +// actor its DNS lookup resolved to instead of failing to parse. func ParseActorDNSName(name string) (ActorRef, error) { - rest, found := strings.CutSuffix(strings.TrimSuffix(name, "."), "."+ActorDNSSuffix) + normalized := lowerASCII(strings.TrimSuffix(name, ".")) + rest, found := strings.CutSuffix(normalized, "."+ActorDNSSuffix) if !found { return ActorRef{}, fmt.Errorf("invalid actor DNS name: must end with %s, got %q", ActorDNSSuffix, name) } diff --git a/internal/resources/actorref_test.go b/internal/resources/actorref_test.go index 63b74069f..a7c8707b0 100644 --- a/internal/resources/actorref_test.go +++ b/internal/resources/actorref_test.go @@ -56,8 +56,14 @@ func TestParseActorDNSName(t *testing.T) { {"valid trailing dot", "act-1.team-a.actors.resources.substrate.ate.dev.", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, {"wrong suffix", "act-1.team-a.example.com", ActorRef{}, true}, {"missing atespace", "act-1.actors.resources.substrate.ate.dev", ActorRef{}, true}, - {"invalid actor name", "ACT-1.team-a.actors.resources.substrate.ate.dev", ActorRef{}, true}, - {"invalid atespace", "act-1.TEAM.actors.resources.substrate.ate.dev", ActorRef{}, true}, + {"mixed-case actor name", "ACT-1.team-a.actors.resources.substrate.ate.dev", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, + {"mixed-case atespace", "act-1.TEAM-A.actors.resources.substrate.ate.dev", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, + {"mixed-case suffix", "act-1.team-a.Actors.Resources.Substrate.Ate.Dev", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, + // strings.ToLower would fold the Kelvin sign onto "k" and hand "act-1k" a + // request addressed to a name no actor can have. + {"non-ASCII actor name", "act-1K.team-a.actors.resources.substrate.ate.dev", ActorRef{}, true}, + {"invalid actor name", "act_1.team-a.actors.resources.substrate.ate.dev", ActorRef{}, true}, + {"invalid atespace", "act-1.team_a.actors.resources.substrate.ate.dev", ActorRef{}, true}, {"host:port not accepted", "act-1.team-a.actors.resources.substrate.ate.dev:8080", ActorRef{}, true}, {"empty", "", ActorRef{}, true}, }