GitHub Security Advisory: GHSA-8hjv-92q9-g4xj
Package: io.micronaut:micronaut-context (MAVEN)
Severity: HIGH (CVSS 7.5)
CWE: CWE-400: Uncontrolled Resource Consumption
Vulnerable versions: >= 4.3.0, < 4.10.22
Patched in: 4.10.22

Summary: Micronaut has unbounded `formattersCache` in `TimeConverterRegistrar` that Allows Memory Exhaustion via `Accept-Language` Header

Details:
## Summary

`TimeConverterRegistrar` caches `DateTimeFormatter` instances in an unbounded `ConcurrentHashMap<String, DateTimeFormatter>` whose key is derived from the `@Format` annotation pattern concatenated with the locale from the HTTP `Accept-Language` header. Because `Locale.forLanguageTag()` accepts arbitrary BCP 47 private-use extensions (`en-x-a001`, `en-x-a002`, …), an unauthenticated attacker can generate an unlimited number of unique cache keys by sending requests with novel locale tags, growing the cache until heap memory is exhausted and the JVM crashes. This is structurally identical to the recently patched GHSA-2hcp-gjrf-7fhc (`DefaultHtmlErrorResponseBodyProvider`), but `TimeConverterRegistrar.formattersCache` was not covered by that fix.

## Details

The vulnerable cache is declared in `context/src/main/java/io/micronaut/runtime/converters/time/TimeConverterRegistrar.java` at line 123:

```java
// TimeConverterRegistrar.java:123
private final Map<String, DateTimeFormatter> formattersCache = new ConcurrentHashMap<>();
```

The `getFormatter` method at line 434 inserts into this map with no eviction or size limit:

```java
// TimeConverterRegistrar.java:434-443
private DateTimeFormatter getFormatter(String pattern, ConversionContext context) {
    var key = pattern + context.getLocale();        // locale from Accept-Language header
    var cachedFormatter = formattersCache.get(key);
    if (cachedFormatter != null) {
        return cachedFormatter;
    }
    var formatter = DateTimeFormatter.ofPattern(pattern, context.getLocale());
    formattersCache.put(key, formatter);            // NO SIZE CHECK — unbounded growth
    return formatter;
}
```

The attacker-controlled locale flows into the cache key through this call chain:

1. **HTTP header parsed** — `HttpHeaders.findAcceptLanguage()` at `http/src/main/java/io/micronaut/http/HttpHeaders.java:766-771` calls `Locale.forLanguageTag(part)` directly on the raw `Accept-Language` value:

```java
// HttpHeaders.java:766-771
default Optional<Locale> findAcceptLanguage() {
    return findFirst(HttpHeaders.ACCEPT_LANGUAGE)
        .map(text -> {
            String part = HttpHeadersUtil.splitAcceptHeader(text);
            return part == null ? Locale.getDefault() : Locale.forLanguageTag(part);
        });
}
```

2. **Locale planted in ConversionContext** — `AbstractRouteMatch.newContext()` at `router/src/main/java/io/micronaut/web/router/AbstractRouteMatch.java:373-378` passes the request locale into the conversion context for every route argument binding:

```java
// AbstractRouteMatch.java:373-378
private <E> ArgumentConversionContext<E> newContext(Argument<E> argument, HttpRequest<?> request) {
    return ConversionContext.of(
        argument,
        request.getLocale().orElse(null),   // ← attacker-controlled via Accept-Language
        request.getCharacterEncoding()
    );
}
```

3. **Unbounded cache insert** — When any temporal argument annotated with `@Format` is bound, `TimeCon