Skip to content

deepObject query parameters are not URL-encoded (regression visible with oapi-codegen v2.7.0) #131

Description

@nogumaruo

Summary

When using deepObject query parameters with non-ASCII values (e.g. CJK characters) or with characters that have special meaning in URLs (&, =, etc.), the generated query string is not URL-encoded, producing invalid URLs that servers reject with HTTP 400 Bad Request.

The root cause is in MarshalDeepObject / marshalDeepObject in deepobject.go: the value (and the nested keys) are concatenated via fmt.Sprintf("%v", t) without ever being passed through url.QueryEscape (or any other escaping function).

This bug has likely existed for a long time, but it was masked by the client generator in oapi-codegen v2.6.0 and earlier, which re-parsed the returned fragment via url.ParseQuery and then called url.Values.Encode(), re-encoding everything before assigning it to RawQuery.

Starting in oapi-codegen v2.7.0 (PR oapi-codegen/oapi-codegen#2307), the generated client no longer re-encodes. It now collects raw fragments and assigns them directly to queryURL.RawQuery:

queryURL.RawQuery = strings.Join(rawQueryFragments, "&")

As a result, the missing escaping in marshalDeepObject is now visible to end users, and any deepObject filter with non-ASCII characters causes a hard failure against compliant HTTP servers.

Reproduction

Go program using runtime v1.4.0:

package main

import (
	"fmt"

	"github.com/oapi-codegen/runtime"
)

func main() {
	filters := map[string]any{
		"name": map[string]any{
			"$eq": "こんにちは",
		},
	}
	queryFrag, err := runtime.StyleParamWithOptions(
		"deepObject", true, "filters", filters,
		runtime.StyleParamOptions{
			ParamLocation: runtime.ParamLocationQuery,
			Type:          "object",
			Format:        "",
		},
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(queryFrag)
}

Actual output

filters[name][$eq]=こんにちは

The CJK characters in the value (and [, ], $ in the key) are emitted as raw bytes. When this string is assigned to url.URL.RawQuery and the request is sent, the wire-level URL is non-RFC3986-compliant and many servers respond with 400 Bad Request (Strapi v4 / Koa-based servers returned 400 with an empty body in our case).

Expected output

filters%5Bname%5D%5B%24eq%5D=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF```

(Values percent-encoded per RFC 3986; reserved characters in keys that need escaping should likewise be encoded.)

## Suggested Fix

In `deepobject.go`, the `default` branch in `marshalDeepObject` builds each fragment without escaping:

```go
default:
    prefix := "[" + strings.Join(path, "][") + "]"
    var value string
    if t == nil {
        value = "null"
    } else {
        value = fmt.Sprintf("%v", t)
    }
    result = []string{
        prefix + fmt.Sprintf("=%s", value), // ← value is not escaped
    }

Proposed change: pass value through url.QueryEscape (matching the previous behavior of url.Values.Encode()), so it is consistent with what oapi-codegen v2.6.0 and earlier produced:

result = []string{
    prefix + "=" + url.QueryEscape(value),
}

The path segments (used as map keys) may also contain user-controlled strings and similarly need to be escaped if they are not guaranteed to be safe ASCII identifiers. A MarshalDeepObjectUnmarshalDeepObject round-trip should still work afterwards because net/url percent-decodes keys and values on parse.

Impact

  • Functional: Any client using deepObject with non-ASCII content (Japanese, Korean, Chinese, Cyrillic, Arabic, emoji, etc.) breaks against RFC3986-compliant servers (HTTP 400).
  • Security: A value containing & or = injects into the query string structure (e.g. ?filter[name]=a&admin=true), which is a classic query-string injection.
  • This is a regression in user-observable behavior between oapi-codegen v2.6.0 (worked) and v2.7.0 (broken) with the same runtime version (v1.4.0).

Versions

  • github.com/oapi-codegen/runtime v1.4.0
  • github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 (regression surfaced here; the underlying bug is in runtime)
  • Go 1.26.1

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions