VYPR
High severityGHSA Advisory· Published May 8, 2026· Updated May 8, 2026

CVE-2026-42272

CVE-2026-42272

Description

Heimdall is a cloud native Identity Aware Proxy and Access Control Decision service. Prior to version 0.17.14, Heimdall handles URL-encoded slashes (%2F) in a case-sensitive manner, while percent-encoding is defined to be case-insensitive. As a result, the lowercase equivalent (%2f) is not recognized and therefore not processed as expected when allow_encoded_slashes is set to off (the default setting). This discrepancy can lead to differences in how request paths are interpreted by heimdall and upstream components, which may result in authorization bypass. This issue has been patched in version 0.17.14.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/dadrus/heimdallGo
< 0.17.140.17.14

Affected products

1

Patches

1
8b0de6aba23a

fix: Case-insensitive handling of URL-encoded slashes (#3207)

https://github.com/dadrus/heimdallDimitrij DrusApr 19, 2026via ghsa
9 files changed · +444 68
  • .golangci.yaml+1 0 modified
    @@ -58,6 +58,7 @@ linters:
           ignore-decls:
             - t testing.T
             - i int
    +        - j int
             - T any
             - m map[string]int
             - w http.ResponseWriter
    
  • go.sum+0 44 modified
    @@ -121,8 +121,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
     github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
     github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
     github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
    -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
    -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
     github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
     github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
     github.com/drone/envsubst/v2 v2.0.0-20210730161058-179042472c46 h1:7QPwrLT79GlD5sizHf27aoY2RTvw62mO6x7mxkScNk0=
    @@ -157,10 +155,6 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
     github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
     github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
     github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
    -github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
    -github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
    -github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc=
    -github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
     github.com/go-co-op/gocron/v2 v2.21.0 h1:e1nt9AEFglarRH9/9y9q0V5sblwxlknpHPjttEajrwQ=
     github.com/go-co-op/gocron/v2 v2.21.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
     github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 h1:zga7zaRE8HCbWjcXMDlfvmQtH0/kMVLo7cQ48dy6kWg=
    @@ -208,8 +202,6 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J
     github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
     github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
     github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
    -github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
    -github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
     github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
     github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
     github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
    @@ -288,8 +280,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
     github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
     github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
     github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
    -github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
    -github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
     github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
     github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
     github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
    @@ -335,12 +325,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
     github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
     github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
     github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
    -github.com/redis/rueidis v1.0.73 h1:0Enrg0VuMdaYyNDDj0lLIheWY0uybCeQOh+jTp2GG3M=
    -github.com/redis/rueidis v1.0.73/go.mod h1:lfdcZzJ1oKGKL37vh9fO3ymwt+0TdjkkUCJxbgpmcgQ=
     github.com/redis/rueidis v1.0.74 h1:J5ZNyxMqX+sDQxQztRI928W6TrERpo+pHSwhftnX7NA=
     github.com/redis/rueidis v1.0.74/go.mod h1:lfdcZzJ1oKGKL37vh9fO3ymwt+0TdjkkUCJxbgpmcgQ=
    -github.com/redis/rueidis/rueidisotel v1.0.73 h1:3UaxKkZcy84MSiBCRHewLZzW1Fwzn0cscqz+oPahoS4=
    -github.com/redis/rueidis/rueidisotel v1.0.73/go.mod h1:/z91Ma5KEbABHZrybdgC+CpNOs8XwboWl9cCpu9KFKs=
     github.com/redis/rueidis/rueidisotel v1.0.74 h1:r5htOMTRelopTCU4+AXQauhANBxcgVXk/01C5RihwQ8=
     github.com/redis/rueidis/rueidisotel v1.0.74/go.mod h1:sGs+LGIl0L11CA6HuFcPMxXXWiqN5CtOxOGJJ/iRhkA=
     github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
    @@ -356,8 +342,6 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a
     github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
     github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
     github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
    -github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
    -github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
     github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
     github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
     github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
    @@ -420,40 +404,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
     go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
     go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
     go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
    -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
    -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
     go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
     go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
    -go.opentelemetry.io/contrib/instrumentation/host v0.67.0 h1:TBZlpWERGQYejjjxOCaul34rx1gM8Dc3erQH3DDe1ng=
    -go.opentelemetry.io/contrib/instrumentation/host v0.67.0/go.mod h1:gY4HjuF4CJmklxYjdJNH3wxBlAehJfX/g/vQo+kL0TU=
     go.opentelemetry.io/contrib/instrumentation/host v0.68.0 h1:0BfTRAHtFpIlIY7cw1qg9nODUwblutIqx7Cn6NPD+2s=
     go.opentelemetry.io/contrib/instrumentation/host v0.68.0/go.mod h1:SmgEeGNt1+gp8bmzB5LLyUlCObWcWrRbYMIiDii3NH8=
    -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
    -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
     go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
     go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
    -go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 h1:fM78cKITJ2r08cl+nw5i+hI9zWAu3iak8o1Os/ca2Ck=
    -go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0/go.mod h1:ybmlzIqGcQzwt5lAfi8TpSnHo/CI3yv1Czodmm+OJa8=
     go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU=
     go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY=
    -go.opentelemetry.io/contrib/propagators/autoprop v0.67.0 h1:XhcQRf4MeqwQw96FcnatDAj6gwE19SUrWZ1VwNg77iE=
    -go.opentelemetry.io/contrib/propagators/autoprop v0.67.0/go.mod h1:7OK06SuNIBIlc5Uq3JGQEsKHuXw29t9OJemvDYyP1dk=
     go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 h1:wLGFvNBPqQhzBn0QRBZjrriH8lZ9gqtTz8ufHEjLg7k=
     go.opentelemetry.io/contrib/propagators/autoprop v0.68.0/go.mod h1:evWK9nCqCzH8nhclTlpkdUzmxrmJQ2mrWCdKIvyOYec=
    -go.opentelemetry.io/contrib/propagators/aws v1.42.0 h1:Kbr3xDxs6kcxp5ThXTKWK2OtwLhNoXBVtqguNYcsZL0=
    -go.opentelemetry.io/contrib/propagators/aws v1.42.0/go.mod h1:Jzw9hZHtxdpCN7x8S17UH59X/EiFivp6VXLs9bdM1OQ=
     go.opentelemetry.io/contrib/propagators/aws v1.43.0 h1:EwnsB3cXRLAh7/Nr/9rMuGw73nfb3z6uAvVDjRrbeUg=
     go.opentelemetry.io/contrib/propagators/aws v1.43.0/go.mod h1:CJjTym6F87tEdm61Qvnz5xrV8vKlH4C92djiqcn62k8=
    -go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
    -go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
     go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
     go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
    -go.opentelemetry.io/contrib/propagators/jaeger v1.42.0 h1:jP8unWI6q5kcb3gpGLjKDGaUa+JW+nHKWvpS/q+YuWA=
    -go.opentelemetry.io/contrib/propagators/jaeger v1.42.0/go.mod h1:xd89e/pUyPatUP1C4z1UknD9jHptESO99tWyvd4mWD4=
     go.opentelemetry.io/contrib/propagators/jaeger v1.43.0 h1:peiLMz1+aqJE+3L4mOVtR9wlmv+yh/JVYXCBjqmzJJE=
     go.opentelemetry.io/contrib/propagators/jaeger v1.43.0/go.mod h1:Agvif+4A8p/3UtZzJ0MCcDEuQwgtrzM71DueU41DCs8=
    -go.opentelemetry.io/contrib/propagators/ot v1.42.0 h1:uQjD1NNqX1+DfcAoWParPt1egNg9vC9gH4xarJ9Khxo=
    -go.opentelemetry.io/contrib/propagators/ot v1.42.0/go.mod h1:yw/c2TCmQLIv109HBOCn6NlJ8Dp7MNfjMcqQZRnAMmg=
     go.opentelemetry.io/contrib/propagators/ot v1.43.0 h1:Hh1HahlGc81AOE7siqi1tVOlbanY/UxMMWedpb0d5oQ=
     go.opentelemetry.io/contrib/propagators/ot v1.43.0/go.mod h1:58MlyS7lghzYvAm5LN9gGmZpCMQEMB5vpZp9SRgOyE4=
     go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
    @@ -538,10 +504,6 @@ google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d h1:hUplc9kLwH374NI
     google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:SpjiK7gGN2j/djoQMxLl3QOe/J/XxNzC5M+YLecVVWU=
     google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
     google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
    -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
    -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
    -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
    -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
     google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
     google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
     google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
    @@ -561,16 +523,10 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
     gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
     gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
     gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
    -k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
    -k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
     k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988=
     k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU=
    -k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
    -k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
     k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds=
     k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc=
    -k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
    -k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
     k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8=
     k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY=
     k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
    
  • internal/rules/route_matcher.go+3 6 modified
    @@ -19,14 +19,14 @@ package rules
     import (
     	"errors"
     	"net/http"
    -	"net/url"
     	"slices"
     	"strings"
     
     	"github.com/dadrus/heimdall/internal/heimdall"
     	"github.com/dadrus/heimdall/internal/rules/config"
     	"github.com/dadrus/heimdall/internal/x/errorchain"
     	"github.com/dadrus/heimdall/internal/x/slicex"
    +	"github.com/dadrus/heimdall/internal/x/urlx"
     )
     
     var (
    @@ -120,15 +120,12 @@ func (m *pathParamMatcher) Matches(request *heimdall.Request, keys, values []str
     	if len(request.URL.RawPath) != 0 {
     		switch m.slashHandling {
     		case config.EncodedSlashesOff:
    -			if strings.Contains(request.URL.RawPath, "%2F") {
    +			if urlx.ContainsEncodedSlash(request.URL.RawPath) {
     				return errorchain.NewWithMessage(ErrRequestPathMismatch,
     					"request path contains encoded slashes which are not allowed")
     			}
    -		case config.EncodedSlashesOn:
    -			value, _ = url.PathUnescape(value)
     		default:
    -			unescaped, _ := url.PathUnescape(strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$"))
    -			value = strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F")
    +			value = urlx.Unescape(value, m.slashHandling == config.EncodedSlashesOn)
     		}
     	}
     
    
  • internal/rules/route_matcher_test.go+29 0 modified
    @@ -413,6 +413,15 @@ func TestPathParamsMatcherMatches(t *testing.T) {
     			values:        []string{"bar%2Fbaz"},
     			toMatch:       "http://example.com/bar%2Fbaz",
     		},
    +		"lowercase encoded slashes are not allowed": {
    +			conf: []config.ParameterMatcher{
    +				{Name: "foo", Type: "exact", Value: "bar%2fbaz"},
    +			},
    +			slashHandling: config.EncodedSlashesOff,
    +			keys:          []string{"foo"},
    +			values:        []string{"bar%2fbaz"},
    +			toMatch:       "http://example.com/bar%2fbaz",
    +		},
     		"matches with path having allowed but not decoded encoded slashes": {
     			conf: []config.ParameterMatcher{
     				{Name: "foo", Type: "exact", Value: "bar%2Fbaz[id]"},
    @@ -423,6 +432,16 @@ func TestPathParamsMatcherMatches(t *testing.T) {
     			toMatch:       "http://example.com/bar%2Fbaz%5Bid%5D",
     			matches:       true,
     		},
    +		"matches with path having allowed but not decoded lowercase encoded slashes": {
    +			conf: []config.ParameterMatcher{
    +				{Name: "foo", Type: "exact", Value: "bar%2fbaz[id]"},
    +			},
    +			slashHandling: config.EncodedSlashesOnNoDecode,
    +			keys:          []string{"foo"},
    +			values:        []string{"bar%2fbaz%5Bid%5D"},
    +			toMatch:       "http://example.com/bar%2fbaz%5Bid%5D",
    +			matches:       true,
    +		},
     		"matches with path having allowed decoded slashes": {
     			conf: []config.ParameterMatcher{
     				{Name: "foo", Type: "exact", Value: "bar/baz[id]"},
    @@ -433,6 +452,16 @@ func TestPathParamsMatcherMatches(t *testing.T) {
     			toMatch:       "http://example.com/foo%2Fbaz%5Bid%5D",
     			matches:       true,
     		},
    +		"matches with path having allowed decoded lowercase encoded slashes": {
    +			conf: []config.ParameterMatcher{
    +				{Name: "foo", Type: "exact", Value: "bar/baz[id]"},
    +			},
    +			slashHandling: config.EncodedSlashesOn,
    +			keys:          []string{"foo"},
    +			values:        []string{"bar%2fbaz%5Bid%5D"},
    +			toMatch:       "http://example.com/foo%2fbaz%5Bid%5D",
    +			matches:       true,
    +		},
     		"does not match the request path if appropriate matcher is not defined as first element": {
     			conf: []config.ParameterMatcher{
     				{Name: "foo", Type: "exact", Value: "bar/foo"},
    
  • internal/rules/rule_impl.go+3 15 modified
    @@ -19,14 +19,14 @@ package rules
     import (
     	"bytes"
     	"net/url"
    -	"strings"
     
     	"github.com/rs/zerolog"
     
     	"github.com/dadrus/heimdall/internal/heimdall"
     	"github.com/dadrus/heimdall/internal/rules/config"
     	"github.com/dadrus/heimdall/internal/rules/rule"
     	"github.com/dadrus/heimdall/internal/x/errorchain"
    +	"github.com/dadrus/heimdall/internal/x/urlx"
     )
     
     type ruleImpl struct {
    @@ -59,7 +59,7 @@ func (r *ruleImpl) Execute(ctx heimdall.RequestContext) (rule.Backend, error) {
     		// unescape path
     		request.URL.RawPath = ""
     	case config.EncodedSlashesOff:
    -		if strings.Contains(request.URL.RawPath, "%2F") {
    +		if urlx.ContainsEncodedSlash(request.URL.RawPath) {
     			return nil, errorchain.NewWithMessage(heimdall.ErrArgument,
     				"path contains encoded slash, which is not allowed")
     		}
    @@ -68,7 +68,7 @@ func (r *ruleImpl) Execute(ctx heimdall.RequestContext) (rule.Backend, error) {
     	// unescape captures
     	captures := request.URL.Captures
     	for k, v := range captures {
    -		captures[k] = unescape(v, r.slashesHandling)
    +		captures[k] = urlx.Unescape(v, r.slashesHandling == config.EncodedSlashesOn)
     	}
     
     	// authenticators
    @@ -164,15 +164,3 @@ type backend struct {
     func (b backend) URL() *url.URL { return b.targetURL }
     
     func (b backend) ForwardHostHeader() bool { return b.forwardHostHeader }
    -
    -func unescape(value string, handling config.EncodedSlashesHandling) string {
    -	if handling == config.EncodedSlashesOn {
    -		unescaped, _ := url.PathUnescape(value)
    -
    -		return unescaped
    -	}
    -
    -	unescaped, _ := url.PathUnescape(strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$"))
    -
    -	return strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F")
    -}
    
  • internal/rules/rule_impl_test.go+105 3 modified
    @@ -199,7 +199,7 @@ func TestRuleExecute(t *testing.T) {
     				assert.Nil(t, backend)
     			},
     		},
    -		"all handler succeed with disallowed urlencoded slashes": {
    +		"all handler succeed with disallowed uppercase urlencoded slashes": {
     			slashHandling: config.EncodedSlashesOff,
     			backend: &config.Backend{
     				Host: "foo.bar",
    @@ -225,6 +225,32 @@ func TestRuleExecute(t *testing.T) {
     				require.ErrorContains(t, err, "path contains encoded slash")
     			},
     		},
    +		"all handler succeed with disallowed lowercase urlencoded slashes": {
    +			slashHandling: config.EncodedSlashesOff,
    +			backend: &config.Backend{
    +				Host: "foo.bar",
    +			},
    +			configureMocks: func(t *testing.T, ctx *heimdallmocks.RequestContextMock, _ *mocks.SubjectCreatorMock,
    +				_ *mocks.SubjectHandlerMock, _ *mocks.SubjectHandlerMock, _ *mocks.ErrorHandlerMock,
    +			) {
    +				t.Helper()
    +
    +				targetURL, _ := url.Parse("http://foo.local/api%2fv1/foo%5Bid%5D")
    +				ctx.EXPECT().Request().Return(&heimdall.Request{
    +					URL: &heimdall.URL{
    +						URL:      *targetURL,
    +						Captures: map[string]string{"first": "api%2fv1", "second": "foo%5Bid%5D"},
    +					},
    +				})
    +			},
    +			assert: func(t *testing.T, err error, _ rule.Backend, _ map[string]string) {
    +				t.Helper()
    +
    +				require.Error(t, err)
    +				require.ErrorIs(t, err, heimdall.ErrArgument)
    +				require.ErrorContains(t, err, "path contains encoded slash")
    +			},
    +		},
     		"all handler succeed with urlencoded slashes on without urlencoded slash": {
     			slashHandling: config.EncodedSlashesOn,
     			backend: &config.Backend{
    @@ -264,7 +290,7 @@ func TestRuleExecute(t *testing.T) {
     				assert.Equal(t, "foo[id]", captures["third"])
     			},
     		},
    -		"all handler succeed with urlencoded slashes on with urlencoded slash": {
    +		"all handler succeed with urlencoded slashes on with uppercase urlencoded slash": {
     			slashHandling: config.EncodedSlashesOn,
     			backend: &config.Backend{
     				Host: "foo.bar",
    @@ -302,7 +328,45 @@ func TestRuleExecute(t *testing.T) {
     				assert.Equal(t, "foo[id]", captures["second"])
     			},
     		},
    -		"all handler succeed with urlencoded slashes on with urlencoded slash but without decoding it": {
    +		"all handler succeed with urlencoded slashes on with lowercase urlencoded slash": {
    +			slashHandling: config.EncodedSlashesOn,
    +			backend: &config.Backend{
    +				Host: "foo.bar",
    +			},
    +			configureMocks: func(t *testing.T, ctx *heimdallmocks.RequestContextMock, authenticator *mocks.SubjectCreatorMock,
    +				authorizer *mocks.SubjectHandlerMock, finalizer *mocks.SubjectHandlerMock,
    +				_ *mocks.ErrorHandlerMock,
    +			) {
    +				t.Helper()
    +
    +				sub := &subject.Subject{ID: "Foo"}
    +
    +				authenticator.EXPECT().Execute(ctx).Return(sub, nil)
    +				authorizer.EXPECT().Execute(ctx, sub).Return(nil)
    +				finalizer.EXPECT().Execute(ctx, sub).Return(nil)
    +
    +				targetURL, _ := url.Parse("http://foo.local/api%2fv1/foo%5Bid%5D")
    +				ctx.EXPECT().Request().Return(&heimdall.Request{
    +					URL: &heimdall.URL{
    +						URL:      *targetURL,
    +						Captures: map[string]string{"first": "api%2fv1", "second": "foo%5Bid%5D"},
    +					},
    +				})
    +			},
    +			assert: func(t *testing.T, err error, backend rule.Backend, captures map[string]string) {
    +				t.Helper()
    +
    +				require.NoError(t, err)
    +
    +				expectedURL, _ := url.Parse("http://foo.bar/api/v1/foo%5Bid%5D")
    +				assert.Equal(t, expectedURL, backend.URL())
    +				assert.True(t, backend.ForwardHostHeader())
    +
    +				assert.Equal(t, "api/v1", captures["first"])
    +				assert.Equal(t, "foo[id]", captures["second"])
    +			},
    +		},
    +		"all handler succeed with urlencoded slashes on with uppercase urlencoded slash but without decoding it": {
     			slashHandling: config.EncodedSlashesOnNoDecode,
     			backend: &config.Backend{
     				Host: "foo.bar",
    @@ -340,6 +404,44 @@ func TestRuleExecute(t *testing.T) {
     				assert.Equal(t, "foo[id]", captures["second"])
     			},
     		},
    +		"all handler succeed with urlencoded slashes on with lowercase urlencoded slash but without decoding it": {
    +			slashHandling: config.EncodedSlashesOnNoDecode,
    +			backend: &config.Backend{
    +				Host: "foo.bar",
    +			},
    +			configureMocks: func(t *testing.T, ctx *heimdallmocks.RequestContextMock, authenticator *mocks.SubjectCreatorMock,
    +				authorizer *mocks.SubjectHandlerMock, finalizer *mocks.SubjectHandlerMock,
    +				_ *mocks.ErrorHandlerMock,
    +			) {
    +				t.Helper()
    +
    +				sub := &subject.Subject{ID: "Foo"}
    +
    +				authenticator.EXPECT().Execute(ctx).Return(sub, nil)
    +				authorizer.EXPECT().Execute(ctx, sub).Return(nil)
    +				finalizer.EXPECT().Execute(ctx, sub).Return(nil)
    +
    +				targetURL, _ := url.Parse("http://foo.local/api%2fv1/foo%5Bid%5D")
    +				ctx.EXPECT().Request().Return(&heimdall.Request{
    +					URL: &heimdall.URL{
    +						URL:      *targetURL,
    +						Captures: map[string]string{"first": "api%2fv1", "second": "foo%5Bid%5D"},
    +					},
    +				})
    +			},
    +			assert: func(t *testing.T, err error, backend rule.Backend, captures map[string]string) {
    +				t.Helper()
    +
    +				require.NoError(t, err)
    +
    +				expectedURL, _ := url.Parse("http://foo.bar/api%2fv1/foo%5Bid%5D")
    +				assert.Equal(t, expectedURL, backend.URL())
    +				assert.True(t, backend.ForwardHostHeader())
    +
    +				assert.Equal(t, "api%2fv1", captures["first"])
    +				assert.Equal(t, "foo[id]", captures["second"])
    +			},
    +		},
     		"stripping path prefix": {
     			backend: &config.Backend{
     				Host:        "foo.bar",
    
  • internal/x/urlx/path_benchmark_test.go+71 0 added
    @@ -0,0 +1,71 @@
    +// Copyright 2026 Dimitrij Drus <dadrus@gmx.de>
    +//
    +// Licensed under the Apache License, Version 2.0 (the "License");
    +// you may not use this file except in compliance with the License.
    +// You may obtain a copy of the License at
    +//
    +//      http://www.apache.org/licenses/LICENSE-2.0
    +//
    +// Unless required by applicable law or agreed to in writing, software
    +// distributed under the License is distributed on an "AS IS" BASIS,
    +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +// See the License for the specific language governing permissions and
    +// limitations under the License.
    +//
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package urlx
    +
    +import "testing"
    +
    +func BenchmarkContainsEncodedSlash(b *testing.B) {
    +	for uc, path := range map[string]string{
    +		"clean_short":        "/api/v1/resource",
    +		"clean_long":         "/api/v1/resource/with/a/longer/path/and/more/segments/for/hot/path/testing",
    +		"encoded_upper":      "/scripts/api%2Fv1/resource",
    +		"encoded_lower":      "/scripts/api%2fv1/resource",
    +		"encoded_upper_long": "/very/long/path/with/many/segments/and/an/encoded/slash/%2F/end",
    +	} {
    +		b.Run(uc, func(b *testing.B) {
    +			b.ReportAllocs()
    +
    +			for b.Loop() {
    +				_ = ContainsEncodedSlash(path)
    +			}
    +		})
    +	}
    +}
    +
    +func BenchmarkUnescapeDecodeSlash(b *testing.B) {
    +	for uc, value := range map[string]string{
    +		"clean":              "api/v1/resource",
    +		"encoded_upper":      "api%2Fv1%5Bid%5D",
    +		"encoded_lower":      "api%2fv1%5Bid%5D",
    +		"encoded_long_mixed": "very%2Flong%2Fpath%2Fwith%2Fmany%2Fparts%5Bid%5D%2Ftail",
    +	} {
    +		b.Run(uc, func(b *testing.B) {
    +			b.ReportAllocs()
    +
    +			for b.Loop() {
    +				_ = Unescape(value, true)
    +			}
    +		})
    +	}
    +}
    +
    +func BenchmarkUnescapePreserveEncodedSlash(b *testing.B) {
    +	for uc, value := range map[string]string{
    +		"clean":              "api/v1/resource",
    +		"encoded_upper":      "api%2Fv1%5Bid%5D",
    +		"encoded_lower":      "api%2fv1%5Bid%5D",
    +		"encoded_long_mixed": "very%2Flong%2Fpath%2Fwith%2Fmany%2Fparts%5Bid%5D%2Ftail",
    +	} {
    +		b.Run(uc, func(b *testing.B) {
    +			b.ReportAllocs()
    +
    +			for b.Loop() {
    +				_ = Unescape(value, false)
    +			}
    +		})
    +	}
    +}
    
  • internal/x/urlx/path.go+121 0 added
    @@ -0,0 +1,121 @@
    +// Copyright 2026 Dimitrij Drus <dadrus@gmx.de>
    +//
    +// Licensed under the Apache License, Version 2.0 (the "License");
    +// you may not use this file except in compliance with the License.
    +// You may obtain a copy of the License at
    +//
    +//      http://www.apache.org/licenses/LICENSE-2.0
    +//
    +// Unless required by applicable law or agreed to in writing, software
    +// distributed under the License is distributed on an "AS IS" BASIS,
    +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +// See the License for the specific language governing permissions and
    +// limitations under the License.
    +//
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package urlx
    +
    +import (
    +	"net/url"
    +	"strings"
    +)
    +
    +// ContainsEncodedSlash reports whether path contains a URL-encoded slash
    +// sequence, case-insensitive, e.g. %2F or %2f.
    +func ContainsEncodedSlash(path string) bool {
    +	for i := strings.IndexByte(path, '%'); i != -1; {
    +		if i+2 < len(path) && path[i+1] == '2' && (path[i+2]|0x20) == 'f' { //nolint:mnd
    +			return true
    +		}
    +
    +		next := strings.IndexByte(path[i+1:], '%')
    +		if next == -1 {
    +			break
    +		}
    +
    +		i += next + 1
    +	}
    +
    +	return false
    +}
    +
    +// Unescape decodes URL-escaped path value.
    +// If decodeEncodedSlash is false, encoded slashes (%2F / %2f) are preserved.
    +func Unescape(value string, decodeEncodedSlash bool) string { //nolint:cyclop
    +	start := strings.IndexByte(value, '%')
    +	if start == -1 {
    +		return value
    +	}
    +
    +	if decodeEncodedSlash {
    +		unescaped, _ := url.PathUnescape(value)
    +
    +		return unescaped
    +	}
    +
    +	var builder strings.Builder
    +	builder.Grow(len(value))
    +	builder.WriteString(value[:start])
    +
    +	for i := start; i < len(value); {
    +		j := i
    +		for j < len(value) && value[j] != '%' {
    +			j++
    +		}
    +
    +		if j > i {
    +			builder.WriteString(value[i:j])
    +			i = j
    +		}
    +
    +		if i >= len(value) {
    +			break
    +		}
    +
    +		if i+2 >= len(value) {
    +			builder.WriteByte(value[i])
    +			i++
    +
    +			continue
    +		}
    +
    +		hi := hexValue(value[i+1])
    +		lo := hexValue(value[i+2])
    +
    +		if hi == 0xFF || lo == 0xFF { //nolint:mnd
    +			builder.WriteByte(value[i])
    +			i++
    +
    +			continue
    +		}
    +
    +		decoded := (hi << 4) | lo //nolint:mnd
    +		if decoded == '/' {
    +			builder.WriteByte(value[i])
    +			builder.WriteByte(value[i+1])
    +			builder.WriteByte(value[i+2])
    +			i += 3
    +
    +			continue
    +		}
    +
    +		builder.WriteByte(decoded)
    +		i += 3
    +	}
    +
    +	return builder.String()
    +}
    +
    +func hexValue(ch byte) byte {
    +	switch {
    +	case ch >= '0' && ch <= '9':
    +		return ch - '0'
    +	case ch >= 'a' && ch <= 'f':
    +		return ch - 'a' + 10 //nolint:mnd
    +	case ch >= 'A' && ch <= 'F':
    +		return ch - 'A' + 10 //nolint:mnd
    +	default:
    +		return 0xFF //nolint:mnd
    +	}
    +}
    
  • internal/x/urlx/path_test.go+111 0 added
    @@ -0,0 +1,111 @@
    +// Copyright 2026 Dimitrij Drus <dadrus@gmx.de>
    +//
    +// Licensed under the Apache License, Version 2.0 (the "License");
    +// you may not use this file except in compliance with the License.
    +// You may obtain a copy of the License at
    +//
    +//      http://www.apache.org/licenses/LICENSE-2.0
    +//
    +// Unless required by applicable law or agreed to in writing, software
    +// distributed under the License is distributed on an "AS IS" BASIS,
    +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +// See the License for the specific language governing permissions and
    +// limitations under the License.
    +//
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package urlx
    +
    +import (
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +)
    +
    +func TestContainsEncodedSlash(t *testing.T) {
    +	t.Parallel()
    +
    +	for uc, tc := range map[string]struct {
    +		path     string
    +		expected bool
    +	}{
    +		"empty": {},
    +		"without escapes": {
    +			path: "/api/v1/resource",
    +		},
    +		"uppercase sequence": {
    +			path:     "/api%2Fv1/resource",
    +			expected: true,
    +		},
    +		"lowercase sequence": {
    +			path:     "/api%2fv1/resource",
    +			expected: true,
    +		},
    +		"mixed in long path": {
    +			path:     "/foo/bar/baz%2Fqux/quux",
    +			expected: true,
    +		},
    +		"not slash escape": {
    +			path: "/api%2Ev1/resource",
    +		},
    +		"incomplete escape": {
    +			path: "/api%2",
    +		},
    +	} {
    +		t.Run(uc, func(t *testing.T) {
    +			assert.Equal(t, tc.expected, ContainsEncodedSlash(tc.path))
    +		})
    +	}
    +}
    +
    +func TestUnescape(t *testing.T) {
    +	t.Parallel()
    +
    +	for uc, tc := range map[string]struct {
    +		value              string
    +		decodeEncodedSlash bool
    +		expected           string
    +	}{
    +		"decode slash on": {
    +			value:              "api%2Fv1",
    +			decodeEncodedSlash: true,
    +			expected:           "api/v1",
    +		},
    +		"decode slash off uppercase": {
    +			value:    "api%2Fv1",
    +			expected: "api%2Fv1",
    +		},
    +		"decode slash off lowercase": {
    +			value:    "api%2fv1",
    +			expected: "api%2fv1",
    +		},
    +		"decode non slash escapes": {
    +			value:    "foo%5Bid%5D",
    +			expected: "foo[id]",
    +		},
    +		"decode mixed preserve slash": {
    +			value:    "api%2Fv1%5Bid%5D",
    +			expected: "api%2Fv1[id]",
    +		},
    +		"decode mixed preserve slash lowercase": {
    +			value:    "api%2fv1%5Bid%5D",
    +			expected: "api%2fv1[id]",
    +		},
    +		"no escapes": {
    +			value:    "api/v1/resource",
    +			expected: "api/v1/resource",
    +		},
    +		"incomplete escape": {
    +			value:    "api%2",
    +			expected: "api%2",
    +		},
    +		"invalid escape": {
    +			value:    "api%ZZv1",
    +			expected: "api%ZZv1",
    +		},
    +	} {
    +		t.Run(uc, func(t *testing.T) {
    +			assert.Equal(t, tc.expected, Unescape(tc.value, tc.decodeEncodedSlash))
    +		})
    +	}
    +}
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

6

News mentions

0

No linked articles in our index yet.