GitHub Security Advisory: GHSA-3rfq-4wpf-qqw3
Package: io.micronaut:micronaut-inject (MAVEN)
Severity: LOW (CVSS 3.7)
CWE: CWE-400: Uncontrolled Resource Consumption
Vulnerable versions: < 4.10.22
Patched in: 4.10.22

Summary: Micronaut has Unbounded `bundleCache` in `ResourceBundleMessageSource` that Allows Memory Exhaustion via `Accept-Language` Header

Details:
## Summary

`ResourceBundleMessageSource` maintains two caches: `messageCache` (bounded at 100 entries via `ConcurrentLinkedHashMap`) and `bundleCache` (unbounded `ConcurrentHashMap`). The `bundleCache` is keyed by `(Locale, baseName)` where the locale originates from the HTTP `Accept-Language` header. In applications that explicitly register a `ResourceBundleMessageSource` bean and serve HTML error responses, an unauthenticated attacker can exhaust heap memory by sending requests with large numbers of unique `Accept-Language` values, each causing a new entry in the unbounded `bundleCache`. Unlike GHSA-2hcp-gjrf-7fhc and the sibling `messageCache` (both bounded), `bundleCache` was not updated to use a bounded cache implementation.

## Details

The `bundleCache` is initialized in `inject/src/main/java/io/micronaut/context/i18n/ResourceBundleMessageSource.java` at line 150:

```java
// ResourceBundleMessageSource.java:139-152
protected Map<MessageKey, Optional<String>> buildMessageCache() {
    return new ConcurrentLinkedHashMap.Builder<MessageKey, Optional<String>>()
            .maximumWeightedCapacity(100)    // ← BOUNDED ✓
            .build();
}

protected Map<MessageKey, Optional<ResourceBundle>> buildBundleCache() {
    return new ConcurrentHashMap<>(18);      // ← UNBOUNDED ✗
}
```

The `resolveBundle()` method at line 169 inserts into `bundleCache` with no eviction policy:

```java
// ResourceBundleMessageSource.java:169-185
private Optional<ResourceBundle> resolveBundle(Locale locale) {
    MessageKey key = new MessageKey(locale, baseName);
    final Optional<ResourceBundle> resourceBundle = bundleCache.get(key);
    if (resourceBundle != null) {
        return resourceBundle;
    } else {
        Optional<ResourceBundle> opt;
        try {
            opt = Optional.of(ResourceBundle.getBundle(baseName, locale, getClassLoader()));
        } catch (MissingResourceException e) {
            opt = Optional.empty();
        }
        bundleCache.put(key, opt);    // NO SIZE CHECK — unbounded growth
        return opt;
    }
}
```

The attack path requires:
1. The application registers a `ResourceBundleMessageSource` bean (non-default, requires explicit user configuration).
2. The attacker sends requests that trigger HTML error responses — i.e., requests with `Accept: text/html` to any URL that returns an error (e.g., 404 for any non-existent path).
3. Each request uses a unique `Accept-Language` value (e.g., `zz-AA`, `zz-AB`, …).
4. `DefaultHtmlErrorResponseBodyProvider.error()` calls `messageSource.getMessage(code, locale)` → `CompositeMessageSource` delegates to `ResourceBundleMessageSource` → `resolveBundle(locale)` inserts one entry per unique locale into `bundleCache`.

For locales that don't match any bundle file, `ResourceBundle.getBundle()` throws `MissingResourceException` and `Optional.empty()` is stored — a low-cost sentinel. For locales that DO match a bundle, a full `ResourceBundle` object is retained in memory. In either case,