fix(localai-proxy): wrap bare base64 image inputs, block unreachable Depth exports, and validate download URLs

Detect/Depth/FaceVerify/FaceAnalyze forwarded the bare base64 core hands the
backend, but the upstream's own REST handlers only accept a URL or a
data:...;base64, string, so every real call 400'd. Wrap the payload as a
data URI (sniffing its MIME type) before sending it.

Depth requests for exports/dst now return Unimplemented: those files are
written to the upstream's own local disk and are unreachable from here, so
failover should move to a local target instead.

Generation replies that hand back a URL are now re-fetched by path only,
checked against the upstream's known generated-content prefixes, instead of
stripping the configured base as a literal string prefix — the old approach
broke (or silently trusted an arbitrary host) the moment the upstream
advertised a different base via LOCALAI_BASE_URL, a reverse proxy, or
X-Forwarded-Host.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-09-27 07:42:21 +00:00
1 parent be9c039e65
commit 37023939ad
2 files changed
+265 -95

No files matched your search

+113 -34
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
@@ -32,6 +33,30 @@ func fileToBase64(path string) (string, error) {
return base64.StdEncoding.EncodeToString(data), nil
}
// toDataURI wraps a base64 payload as a data URI. Detect/Depth/FaceVerify/
// FaceAnalyze's REST endpoints decode their image field with
// utils.GetContentURIAsBase64, which only accepts an http(s) URL or a
// `data:<mime>;base64,<payload>` string — never bare base64. That is exactly
// what core hands the backend for these methods (see
// core/http/endpoints/localai/images.go's decodeImageInput, which already
// stripped any data: prefix off before we ever see it), so forwarding it
// unwrapped 400s on every real call. The MIME type isn't carried alongside
// the payload, so it's sniffed from the decoded bytes.
func toDataURI(b64 string) (string, error) {
if b64 == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", status.Errorf(codes.InvalidArgument, "localai-proxy: decode base64 image: %v", err)
}
mime := http.DetectContentType(data)
if mime == "application/octet-stream" {
mime = "image/png"
}
return "data:" + mime + ";base64," + b64, nil
}
// genItem is one schema.Item as the image/video/3D generation endpoints
// return it: either inline base64 data, or a URL to download the asset from.
type genItem struct {
@@ -66,20 +91,42 @@ func (p *LocalAIProxy) writeGenItem(ctx context.Context, path string, items []ge
if item.URL == "" {
return status.Errorf(codes.Internal, "localai-proxy: upstream %s returned neither b64_json nor url", path)
}
return p.getToFile(ctx, p.relativePath(item.URL), dst)
rel, err := generatedContentPath(path, item.URL)
if err != nil {
return err
}
return p.getToFile(ctx, rel, dst)
}
// relativePath strips the configured upstream base from a URL the upstream
// handed back (e.g. "<upstream>/generated-images/x.png"), so the follow-up
// download goes through the normal request path instead of concatenating two
// absolute URLs.
func (p *LocalAIProxy) relativePath(raw string) string {
if cfg := p.cfg.Load(); cfg != nil {
if rel, ok := strings.CutPrefix(raw, cfg.base); ok {
return rel
// generatedContentPrefixes are the static paths a LocalAI instance serves
// generated media under (core/http/app.go's e.Static calls). A generation
// reply's URL is only ever safe to re-fetch through this proxy's own
// authenticated client when it resolves to one of these.
var generatedContentPrefixes = []string{"/generated-images/", "/generated-videos/", "/generated-audio/", "/generated-3d/"}
// generatedContentPath extracts the path to re-download raw from, ignoring
// whatever host is in it. A literal-prefix strip of the configured upstream
// base (the previous approach) breaks the moment the upstream advertises a
// different host than the one this proxy is configured with — LOCALAI_BASE_URL,
// a reverse proxy, or X-Forwarded-Host can all change it — so only the path is
// trusted, and only when it is one this proxy's upstream is actually known to
// serve generated media under; anything else could point anywhere, and taking
// it on faith would let a compromised or misconfigured upstream make this
// proxy fetch (with its bearer key) whatever URL it likes.
func generatedContentPath(callPath, raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", status.Errorf(codes.Internal, "localai-proxy: upstream %s returned an invalid url %q: %v", callPath, raw, err)
}
for _, prefix := range generatedContentPrefixes {
if strings.HasPrefix(u.Path, prefix) {
if u.RawQuery != "" {
return u.Path + "?" + u.RawQuery, nil
}
return u.Path, nil
}
}
return raw
return "", status.Errorf(codes.InvalidArgument, "localai-proxy: upstream %s returned an unexpected url %q", callPath, raw)
}
// --- Images ---------------------------------------------------------
@@ -335,13 +382,19 @@ type detectResponseBody struct {
Detections []detectionBody `json:"detections"`
}
// Detect posts Src, which core already carries as a base64 payload (the same
// convention DetectionEndpoint uses to call this method locally), and maps
// each detection, decoding its PNG mask.
// Detect posts Src, which core already carries as a bare base64 payload (the
// same convention DetectionEndpoint uses to call this method locally) — but
// the upstream's own endpoint only accepts a URL or a data URI, so it is
// re-wrapped as one (see toDataURI) — and maps each detection, decoding its
// PNG mask.
func (p *LocalAIProxy) Detect(req *pb.DetectOptions) (pb.DetectResponse, error) {
image, err := toDataURI(req.GetSrc())
if err != nil {
return pb.DetectResponse{}, err
}
body := detectRequestBody{
Model: p.model(""),
Image: req.GetSrc(),
Image: image,
Prompt: req.GetPrompt(),
Points: req.GetPoints(),
Boxes: req.GetBoxes(),
@@ -371,17 +424,18 @@ func (p *LocalAIProxy) Detect(req *pb.DetectOptions) (pb.DetectResponse, error)
return pb.DetectResponse{Detections: detections}, nil
}
// depthRequestBody has no Dst/Exports fields: Depth refuses those requests
// before building the body (see the Depth doc comment), so they never reach
// the upstream.
type depthRequestBody struct {
Model string `json:"model"`
Image string `json:"image"`
Dst string `json:"dst,omitempty"`
IncludeDepth bool `json:"include_depth,omitempty"`
IncludeConfidence bool `json:"include_confidence,omitempty"`
IncludePose bool `json:"include_pose,omitempty"`
IncludeSky bool `json:"include_sky,omitempty"`
IncludePoints bool `json:"include_points,omitempty"`
PointsConfThresh float32 `json:"points_conf_thresh,omitempty"`
Exports []string `json:"exports,omitempty"`
Model string `json:"model"`
Image string `json:"image"`
IncludeDepth bool `json:"include_depth,omitempty"`
IncludeConfidence bool `json:"include_confidence,omitempty"`
IncludePose bool `json:"include_pose,omitempty"`
IncludeSky bool `json:"include_sky,omitempty"`
IncludePoints bool `json:"include_points,omitempty"`
PointsConfThresh float32 `json:"points_conf_thresh,omitempty"`
}
type depthResponseBody struct {
@@ -399,20 +453,29 @@ type depthResponseBody struct {
IsMetric bool `json:"is_metric"`
}
// Depth posts Src (a base64 payload, per the same convention as Detect) and
// maps the full response, decoding the point-cloud color bytes.
// Depth posts Src (a bare base64 payload, per the same convention as Detect,
// re-wrapped as a data URI via toDataURI) and maps the full response,
// decoding the point-cloud color bytes. A request for exports or a dst
// directory is refused: those would be written to the upstream's own local
// disk, and ExportPaths would name files this proxy (and whatever asked it
// for them) can never reach.
func (p *LocalAIProxy) Depth(req *pb.DepthRequest) (pb.DepthResponse, error) {
if req.GetDst() != "" || len(req.GetExports()) > 0 {
return pb.DepthResponse{}, unimplemented("Depth exports (written to the upstream's own local disk, unreachable from here)")
}
image, err := toDataURI(req.GetSrc())
if err != nil {
return pb.DepthResponse{}, err
}
body := depthRequestBody{
Model: p.model(""),
Image: req.GetSrc(),
Dst: req.GetDst(),
Image: image,
IncludeDepth: req.GetIncludeDepth(),
IncludeConfidence: req.GetIncludeConfidence(),
IncludePose: req.GetIncludePose(),
IncludeSky: req.GetIncludeSky(),
IncludePoints: req.GetIncludePoints(),
PointsConfThresh: req.GetPointsConfThresh(),
Exports: req.GetExports(),
}
var resp depthResponseBody
if err := p.postJSON(context.Background(), "/v1/depth", body, &resp); err != nil {
@@ -472,11 +535,21 @@ type faceVerifyResponseBody struct {
Img2AntispoofScore *float32 `json:"img2_antispoof_score,omitempty"`
}
// FaceVerify posts Img1/Img2, which core already carries as base64 (the same
// convention FaceVerifyEndpoint uses to call this method locally).
// FaceVerify posts Img1/Img2, which core already carries as bare base64 (the
// same convention FaceVerifyEndpoint uses to call this method locally) —
// re-wrapped as data URIs via toDataURI, since the upstream's own endpoint
// only accepts a URL or a data URI.
func (p *LocalAIProxy) FaceVerify(req *pb.FaceVerifyRequest) (pb.FaceVerifyResponse, error) {
img1, err := toDataURI(req.GetImg1())
if err != nil {
return pb.FaceVerifyResponse{}, err
}
img2, err := toDataURI(req.GetImg2())
if err != nil {
return pb.FaceVerifyResponse{}, err
}
body := faceVerifyRequestBody{
Model: p.model(""), Img1: req.GetImg1(), Img2: req.GetImg2(),
Model: p.model(""), Img1: img1, Img2: img2,
Threshold: req.GetThreshold(), AntiSpoofing: req.GetAntiSpoofing(),
}
var resp faceVerifyResponseBody
@@ -536,10 +609,16 @@ type faceAnalyzeResponseBody struct {
Faces []faceAnalysisBody `json:"faces"`
}
// FaceAnalyze posts Img, which core already carries as base64.
// FaceAnalyze posts Img, which core already carries as bare base64 —
// re-wrapped as a data URI via toDataURI, since the upstream's own endpoint
// only accepts a URL or a data URI.
func (p *LocalAIProxy) FaceAnalyze(req *pb.FaceAnalyzeRequest) (pb.FaceAnalyzeResponse, error) {
img, err := toDataURI(req.GetImg())
if err != nil {
return pb.FaceAnalyzeResponse{}, err
}
body := faceAnalyzeRequestBody{
Model: p.model(""), Img: req.GetImg(), Actions: req.GetActions(), AntiSpoofing: req.GetAntiSpoofing(),
Model: p.model(""), Img: img, Actions: req.GetActions(), AntiSpoofing: req.GetAntiSpoofing(),
}
var resp faceAnalyzeResponseBody
if err := p.postJSON(context.Background(), "/v1/face/analyze", body, &resp); err != nil {
+152 -61
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/base64"
"encoding/json"
"net/http"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"google.golang.org/grpc/codes"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/utils"
)
// b64 is the base64 encoding a spec expects the proxy to have produced from
@@ -84,6 +86,38 @@ var _ = Describe("media methods", func() {
Expect(codeOf(err)).To(Equal(codes.Unavailable))
Expect(dst).NotTo(BeAnExistingFile())
})
It("downloads a reply URL from the configured upstream even when its host does not match", func() {
// A reverse proxy, LOCALAI_BASE_URL, or X-Forwarded-Host can all make
// the upstream hand back a URL on a different host than the one this
// proxy is configured with. Only the path should matter: the proxy
// must still fetch it from cfg.base (this fake upstream), with its
// own bearer key, never from the host named in the URL.
p := loadProxy(up, nil)
up.replyJSON("/v1/images/generations", map[string]any{
"data": []map[string]any{{"url": "http://mismatched-host.invalid:1/generated-images/out.png"}},
})
up.script("/generated-images/out.png", scriptedResponse{Status: http.StatusOK, ContentType: "image/png", Body: "downloaded-bytes"})
dst := filepath.Join(GinkgoT().TempDir(), "out.png")
Expect(p.GenerateImage(&pb.GenerateImageRequest{PositivePrompt: "a cat", Dst: dst})).To(Succeed())
got, err := os.ReadFile(dst)
Expect(err).NotTo(HaveOccurred())
Expect(string(got)).To(Equal("downloaded-bytes"))
})
It("refuses a reply URL whose path is not a known generated-content path", func() {
p := loadProxy(up, nil)
up.replyJSON("/v1/images/generations", map[string]any{
"data": []map[string]any{{"url": up.URL + "/etc/passwd"}},
})
dst := filepath.Join(GinkgoT().TempDir(), "out.png")
err := p.GenerateImage(&pb.GenerateImageRequest{PositivePrompt: "a cat", Dst: dst})
Expect(codeOf(err)).To(Equal(codes.InvalidArgument))
Expect(dst).NotTo(BeAnExistingFile())
})
})
Describe("UpscaleImage", func() {
@@ -211,105 +245,162 @@ var _ = Describe("media methods", func() {
})
})
Describe("Detect", func() {
It("posts the image and maps detections including the mask", func() {
p := loadProxy(up, nil)
mask := base64.StdEncoding.EncodeToString([]byte("png-mask"))
up.replyJSON("/v1/detection", map[string]any{
"detections": []map[string]any{
{"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, "confidence": 0.9, "class_name": "cat", "mask": mask},
},
})
// pngBytes is a minimal payload with a real PNG magic number, so
// http.DetectContentType (which toDataURI uses) sniffs "image/png" the
// way it would for a real image, and decodeAndCheckImage below can tell
// this test wrote a valid data URI apart from one that merely echoes bare
// base64.
pngBytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("fake-png-body")...)
res, err := p.Detect(&pb.DetectOptions{Src: "base64-image-data", Prompt: "cat", Threshold: 0.5})
// decodeAndCheckImage extracts field from an upstream request body and
// decodes it exactly the way the real REST handlers do — with
// utils.GetContentURIAsBase64 (core/http/endpoints/localai/images.go's
// decodeImageInput calls the same function) — so a spec here fails the
// same way a live upstream would if the proxy ever regressed to sending
// bare base64, which that function rejects outright.
decodeAndCheckImage := func(body map[string]any, field string, want []byte) {
raw, _ := body[field].(string)
decoded, err := utils.GetContentURIAsBase64(raw)
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "the real upstream would reject %s the same way", field)
got, err := base64.StdEncoding.DecodeString(decoded)
ExpectWithOffset(1, err).NotTo(HaveOccurred())
ExpectWithOffset(1, got).To(Equal(want))
}
Describe("Detect", func() {
It("wraps the bare base64 image as a data URI the real upstream decoder accepts, and maps detections", func() {
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
decodeAndCheckImage(body, "image", pngBytes)
Expect(body["prompt"]).To(Equal("cat"))
mask := base64.StdEncoding.EncodeToString([]byte("png-mask"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"detections": []map[string]any{
{"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, "confidence": 0.9, "class_name": "cat", "mask": mask},
},
})
})
DeferCleanup(up.Close)
p := loadProxy(up, nil)
res, err := p.Detect(&pb.DetectOptions{
Src: base64.StdEncoding.EncodeToString(pngBytes), Prompt: "cat", Threshold: 0.5,
})
Expect(err).NotTo(HaveOccurred())
Expect(res.Detections).To(HaveLen(1))
Expect(res.Detections[0].ClassName).To(Equal("cat"))
Expect(res.Detections[0].Confidence).To(BeNumerically("~", 0.9, 1e-6))
Expect(res.Detections[0].Mask).To(Equal([]byte("png-mask")))
req := up.last()
Expect(req.Path).To(Equal("/v1/detection"))
Expect(req.JSON).To(HaveKeyWithValue("image", "base64-image-data"))
Expect(req.JSON).To(HaveKeyWithValue("prompt", "cat"))
Expect(req.JSON).To(HaveKeyWithValue("threshold", BeNumerically("~", 0.5, 1e-6)))
})
})
Describe("Depth", func() {
It("posts the image and maps the full depth response", func() {
p := loadProxy(up, nil)
colors := base64.StdEncoding.EncodeToString([]byte("rgb"))
up.replyJSON("/v1/depth", map[string]any{
"width": 2, "height": 1, "depth": []float64{0.1, 0.2},
"point_colors": colors, "is_metric": true,
})
It("wraps the bare base64 image as a data URI and maps the full depth response", func() {
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
decodeAndCheckImage(body, "image", pngBytes)
Expect(body["include_depth"]).To(Equal(true))
res, err := p.Depth(&pb.DepthRequest{Src: "base64-image-data", IncludeDepth: true})
colors := base64.StdEncoding.EncodeToString([]byte("rgb"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"width": 2, "height": 1, "depth": []float64{0.1, 0.2},
"point_colors": colors, "is_metric": true,
})
})
DeferCleanup(up.Close)
p := loadProxy(up, nil)
res, err := p.Depth(&pb.DepthRequest{Src: base64.StdEncoding.EncodeToString(pngBytes), IncludeDepth: true})
Expect(err).NotTo(HaveOccurred())
Expect(res.Width).To(Equal(int32(2)))
Expect(res.Height).To(Equal(int32(1)))
Expect(res.Depth).To(Equal([]float32{0.1, 0.2}))
Expect(res.PointColors).To(Equal([]byte("rgb")))
Expect(res.IsMetric).To(BeTrue())
})
req := up.last()
Expect(req.Path).To(Equal("/v1/depth"))
Expect(req.JSON).To(HaveKeyWithValue("image", "base64-image-data"))
Expect(req.JSON).To(HaveKeyWithValue("include_depth", true))
It("refuses a request for exports or a dst directory without calling the upstream, so failover moves on", func() {
p := loadProxy(up, nil)
_, exportsErr := p.Depth(&pb.DepthRequest{Src: "irrelevant", Exports: []string{"glb"}})
Expect(codeOf(exportsErr)).To(Equal(codes.Unimplemented))
_, dstErr := p.Depth(&pb.DepthRequest{Src: "irrelevant", Dst: "/some/output/dir"})
Expect(codeOf(dstErr)).To(Equal(codes.Unimplemented))
Expect(up.recorded()).To(BeEmpty(), "an exports/dst request must never reach the upstream")
})
})
Describe("FaceVerify", func() {
It("posts both images and maps the response, including liveness fields", func() {
p := loadProxy(up, nil)
up.replyJSON("/v1/face/verify", map[string]any{
"verified": true, "distance": 0.1, "threshold": 0.4, "confidence": 92.0, "model": "buffalo_l",
"img1_area": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"img2_area": map[string]any{"x": 5.0, "y": 6.0, "w": 7.0, "h": 8.0},
"img1_is_real": true, "img1_antispoof_score": 0.99,
"img2_is_real": false, "img2_antispoof_score": 0.1,
})
It("wraps both bare base64 images as data URIs and maps the response, including liveness fields", func() {
img2Bytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("other-face")...)
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
decodeAndCheckImage(body, "img1", pngBytes)
decodeAndCheckImage(body, "img2", img2Bytes)
Expect(body["anti_spoofing"]).To(Equal(true))
res, err := p.FaceVerify(&pb.FaceVerifyRequest{Img1: "img1-b64", Img2: "img2-b64", AntiSpoofing: true})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"verified": true, "distance": 0.1, "threshold": 0.4, "confidence": 92.0, "model": "buffalo_l",
"img1_area": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"img2_area": map[string]any{"x": 5.0, "y": 6.0, "w": 7.0, "h": 8.0},
"img1_is_real": true, "img1_antispoof_score": 0.99,
"img2_is_real": false, "img2_antispoof_score": 0.1,
})
})
DeferCleanup(up.Close)
p := loadProxy(up, nil)
res, err := p.FaceVerify(&pb.FaceVerifyRequest{
Img1: base64.StdEncoding.EncodeToString(pngBytes), Img2: base64.StdEncoding.EncodeToString(img2Bytes),
AntiSpoofing: true,
})
Expect(err).NotTo(HaveOccurred())
Expect(res.Verified).To(BeTrue())
Expect(res.Model).To(Equal("buffalo_l"))
Expect(res.Img1Area.W).To(BeNumerically("==", 3))
Expect(res.Img1IsReal).To(BeTrue())
Expect(res.Img2IsReal).To(BeFalse())
req := up.last()
Expect(req.Path).To(Equal("/v1/face/verify"))
Expect(req.JSON).To(HaveKeyWithValue("img1", "img1-b64"))
Expect(req.JSON).To(HaveKeyWithValue("img2", "img2-b64"))
Expect(req.JSON).To(HaveKeyWithValue("anti_spoofing", true))
})
})
Describe("FaceAnalyze", func() {
It("posts the image and maps per-face demographic attributes", func() {
p := loadProxy(up, nil)
up.replyJSON("/v1/face/analyze", map[string]any{
"faces": []map[string]any{
{
"region": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"face_confidence": 0.95, "age": 30.0, "dominant_gender": "Man",
"gender": map[string]any{"Man": 0.9, "Woman": 0.1},
},
},
})
It("wraps the bare base64 image as a data URI and maps per-face demographic attributes", func() {
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
decodeAndCheckImage(body, "img", pngBytes)
Expect(body["actions"]).To(ConsistOf("age", "gender"))
res, err := p.FaceAnalyze(&pb.FaceAnalyzeRequest{Img: "img-b64", Actions: []string{"age", "gender"}})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"faces": []map[string]any{
{
"region": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"face_confidence": 0.95, "age": 30.0, "dominant_gender": "Man",
"gender": map[string]any{"Man": 0.9, "Woman": 0.1},
},
},
})
})
DeferCleanup(up.Close)
p := loadProxy(up, nil)
res, err := p.FaceAnalyze(&pb.FaceAnalyzeRequest{
Img: base64.StdEncoding.EncodeToString(pngBytes), Actions: []string{"age", "gender"},
})
Expect(err).NotTo(HaveOccurred())
Expect(res.Faces).To(HaveLen(1))
Expect(res.Faces[0].DominantGender).To(Equal("Man"))
Expect(res.Faces[0].Gender).To(HaveKeyWithValue("Man", Equal(float32(0.9))))
req := up.last()
Expect(req.Path).To(Equal("/v1/face/analyze"))
Expect(req.JSON).To(HaveKeyWithValue("img", "img-b64"))
Expect(req.JSON).To(HaveKeyWithValue("actions", ConsistOf("age", "gender")))
})
})