package game // Regression: the account name typed at the prompt must be validated with // player.ValidName BEFORE it reaches the filesystem. The name is later stored // on the session and reused to build account/character paths, so an unvalidated // "../../" style name must never be joined into a path, even if such a path // happens to resolve to an existing file. import ( "os" "path/filepath" "strings" "testing" "thehouseoficarus/internal/net" ) func TestAccountNameValidatedBeforeFilesystemAccess(t *testing.T) { h := newHarness(t) h.seedAccount("alice") // A file that "../escape" resolves to once joined under players/accounts/ // (filepath.Join cleans the traversal). Before the fix this name reached // FindAccount first and was accepted into the password state. escapePath := filepath.Join(h.dir, "players", "escape.yaml") if err := os.WriteFile(escapePath, []byte("name: escape\npassword_hash: x\n"), 0600); err != nil { t.Fatal(err) } conn := newChanConn() sess := &net.Session{Conn: conn} sess.SetState(net.StateAccountName) h.g.RunOnOwner(func() { h.g.handleAccountName(sess, "../escape") }) if got := sess.State(); got != net.StateAccountName { t.Fatalf("traversal account name accepted: state = %v, want StateAccountName", got) } if sess.Account != nil { t.Fatalf("rejected name bound to the session: %+v", sess.Account) } if out := string(conn.Bytes()); !strings.Contains(out, "name must be") { t.Fatalf("missing name-validation message; output = %q", out) } // A valid existing name still reaches the password prompt. conn2 := newChanConn() valid := &net.Session{Conn: conn2} valid.SetState(net.StateAccountName) h.g.RunOnOwner(func() { h.g.handleAccountName(valid, "alice") }) if got := valid.State(); got != net.StatePassword { t.Fatalf("valid account name rejected: state = %v, want StatePassword", got) } if valid.Account == nil || valid.Account.Name != "alice" { t.Fatalf("valid account name not bound: %+v", valid.Account) } }