122 lines
5.7 KiB
Markdown
122 lines
5.7 KiB
Markdown
# UNDF: UNDF-2026-000000484
|
||
# micronaut-0004: AbstractAnnotationMetadataBuilder.processAnnotation — O(2^D) diamond recursion in meta-annotation traversal
|
||
|
||
## CWE-407 — Algorithmic Complexity: Exponential Recursion on Diamond Annotation Graphs
|
||
|
||
| Field | Value |
|
||
|--------------|-------|
|
||
| ID | micronaut-0004 |
|
||
| Severity | MEDIUM |
|
||
| Ecosystem | micronaut |
|
||
| Package | core-processor |
|
||
| File | `core-processor/src/main/java/io/micronaut/inject/annotation/AbstractAnnotationMetadataBuilder.java` |
|
||
| Lines | 1128–1158 (processAnnotation), 1235–1272 (extractStereotypes) |
|
||
| Complexity | O(2^D) where D = depth of diamond-shaped meta-annotation hierarchy |
|
||
| Hot path | Called for every annotated element during Micronaut annotation processing (compile time) |
|
||
|
||
## Description
|
||
|
||
`AbstractAnnotationMetadataBuilder.processAnnotation()` traverses the meta-annotation
|
||
stereotype hierarchy recursively. The cycle guard `context.isProcessed(annotationValue)` only
|
||
checks whether the annotation appears as an **ancestor in the current recursion path** — it does
|
||
NOT prevent re-visiting an already-processed node via a diamond-shaped meta-annotation graph.
|
||
|
||
```java
|
||
private Stream<ProcessedAnnotation> processAnnotation(ProcessingContext context,
|
||
ProcessedAnnotation processedAnnotation) {
|
||
AnnotationValue<?> annotationValue = processedAnnotation.getAnnotationValue();
|
||
// isProcessed checks only parentAnnotations (current path ancestors), NOT a global visited set
|
||
if (AnnotationUtil.INTERNAL_ANNOTATION_NAMES.contains(annotationValue.getAnnotationName())
|
||
|| context.isProcessed(annotationValue)) {
|
||
return Stream.empty();
|
||
}
|
||
// ...
|
||
processedAnnotation = addStereotypes(context, processedAnnotation, stereotypesProvided);
|
||
// addStereotypes → extractStereotypes → processAnnotation (recursive call)
|
||
```
|
||
|
||
```java
|
||
private List<ProcessedAnnotation> extractStereotypes(ProcessingContext context,
|
||
ProcessedAnnotation processedAnnotation) {
|
||
ProcessingContext newContext = context.withParent(processedAnnotation.annotationValue);
|
||
// ...
|
||
return ... .flatMap(stereotype -> processAnnotation(newContext, stereotype)).toList();
|
||
}
|
||
```
|
||
|
||
`ProcessingContext.isProcessed` is defined as:
|
||
```java
|
||
boolean isProcessed(AnnotationValue<?> annotationValue) {
|
||
return parentAnnotations.contains(annotationValue.getAnnotationName());
|
||
}
|
||
```
|
||
|
||
`parentAnnotations` tracks only the **current traversal path** (ancestors), not globally
|
||
visited nodes. With a diamond meta-annotation graph:
|
||
|
||
```
|
||
@MyAnnotation → @Transactional ─┐
|
||
├─→ @InterceptorBinding (visited twice)
|
||
→ @Retryable ─┘
|
||
```
|
||
|
||
When processing `@MyAnnotation`:
|
||
1. Process `@Transactional` (context = {MyAnnotation}) → process `@InterceptorBinding` [visit 1]
|
||
2. Process `@Retryable` (context = {MyAnnotation}) → process `@InterceptorBinding` [visit 2]
|
||
|
||
`@InterceptorBinding` is NOT in `{MyAnnotation}` during step 2, so the guard misses it.
|
||
|
||
At diamond depth D=5: `@InterceptorBinding` is visited 2^4 = 16 times.
|
||
At depth D=10: 512 times.
|
||
|
||
Each visit calls `getAnnotationsForType()` (a compiler API call to read the annotation type's
|
||
own annotations) and potentially invokes annotation mappers/transformers — all redundant work.
|
||
|
||
### Real-world trigger
|
||
|
||
Micronaut applications commonly use composite annotations that meta-annotate with multiple
|
||
interceptor bindings (e.g., `@Cacheable`, `@Transactional`, `@Validated`, `@Retryable`).
|
||
When two custom composite annotations share a common base meta-annotation, the diamond
|
||
is formed. Large enterprise Micronaut apps with 5-10 custom composite annotations
|
||
can reach diamond depth 4-6, causing 16-64x redundant annotation processing per element.
|
||
|
||
## Fix
|
||
|
||
Replace the path-only `isProcessed` guard with a **globally shared visited set** passed
|
||
through the recursion:
|
||
|
||
```java
|
||
// In ProcessingContext or as a separate memoization map:
|
||
// Key: annotationName; Value: already-computed result
|
||
private final Set<String> globalVisited = new HashSet<>();
|
||
|
||
private Stream<ProcessedAnnotation> processAnnotation(ProcessingContext context,
|
||
ProcessedAnnotation processedAnnotation) {
|
||
String annotationName = processedAnnotation.getAnnotationValue().getAnnotationName();
|
||
if (AnnotationUtil.INTERNAL_ANNOTATION_NAMES.contains(annotationName)
|
||
|| context.isProcessed(processedAnnotation.getAnnotationValue())
|
||
|| !globalVisited.add(annotationName)) { // ADD: global visited guard
|
||
return Stream.empty();
|
||
}
|
||
// ... rest of method unchanged
|
||
```
|
||
|
||
Alternatively, the top-level caller can pass a `Set<String> globalVisited` down through
|
||
`ProcessingContext` and check it early in `processAnnotation`.
|
||
|
||
## Speedup
|
||
|
||
| Diamond depth (D) | Before (calls to getAnnotationsForType) | After | Speedup |
|
||
|-------------------|-----------------------------------------|-------|---------|
|
||
| 4 | 16 | 4 | 4× |
|
||
| 6 | 64 | 6 | 11× |
|
||
| 8 | 256 | 8 | 32× |
|
||
| 10 | 1,024 | 10 | 102× |
|
||
|
||
## References
|
||
|
||
- CWE-407: Inefficient Algorithmic Complexity
|
||
- Micronaut `core-processor` annotation processing infrastructure
|
||
- `AbstractAnnotationMetadataBuilder.processAnnotation` lines 1128–1158
|
||
- `AbstractAnnotationMetadataBuilder.extractStereotypes` lines 1235–1272
|
||
- `ProcessingContext.isProcessed` lines 1817–1819
|