• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zalando / logbook / 5763

27 Jul 2026 11:09AM UTC coverage: 96.601% (-0.3%) from 96.858%
5763

push

github

web-flow
Fix JSON formatter embedding non-JSON body as raw value (#2326)

* Fix JSON formatter embedding non-JSON body as raw value

When a request body has a JSON content-type header but contains
non-JSON content (e.g. Base64-encoded ciphertext, binary data, or
truncated/malformed JSON), the formatters were embedding the raw
body directly using @JsonRawValue / writeRawValue(), producing
malformed JSON log output that breaks Logstash and Elasticsearch
ingestion.

Fix applied to all four formatters:
- JsonHttpLogFormatter (Jackson 3)
- FastJsonHttpLogFormatter (Jackson 3)
- JsonHttpLogFormatterJackson2 (Jackson 2)
- FastJsonHttpLogFormatterJackson2 (Jackson 2)

Each formatter now uses a two-stage validation approach:
1. Fast path: check first non-whitespace character against valid
   JSON start characters ({ [ " digit - t f n). Obvious non-JSON
   content like ciphertext is rejected immediately at zero parse cost.
2. Slow path: only if the fast check passes, attempt full JSON
   validation via readTree(). If parsing fails, fall back to logging
   the body as a safely quoted plain string.

This ensures the overall JSON log structure is always valid while
preserving existing behaviour for valid JSON bodies and keeping
the performance impact minimal for the common case.

Also resolves the long-standing TODO comment in
JsonHttpLogFormatterJackson2.prepareBody() regarding missing
JSON validation.

Test assertions in WriteBodyMaxSizeTest updated to reflect the
corrected behaviour: truncated bodies (produced by BodyMaxSizeFilter)
are now correctly logged as quoted strings rather than raw invalid JSON.

Fixes #2318

* Fix JSON formatter embedding non-JSON body as raw value

Extract shared JsonUtil validation helper per module. Add opt-in
validateJsonBody flag (default: false) to all four formatters and
wire it through LogbookProperties for Spring Boot users.

Fixes #2318

* Rename JsonUtil to JsonUtilJackson2 to fix duplicate-finder plugin

* Address review feedback: p... (continued)

874 of 922 branches covered (94.79%)

70 of 83 new or added lines in 7 files covered. (84.34%)

4007 of 4148 relevant lines covered (96.6%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.33
/logbook-json/src/main/java/org/zalando/logbook/json/FastJsonHttpLogFormatter.java
1
package org.zalando.logbook.json;
2

3
import tools.jackson.core.JsonGenerator;
4
import tools.jackson.core.ObjectWriteContext;
5
import tools.jackson.core.json.JsonFactory;
6
import tools.jackson.databind.json.JsonMapper;
7
import lombok.AllArgsConstructor;
8
import lombok.extern.slf4j.Slf4j;
9
import org.apiguardian.api.API;
10
import org.zalando.logbook.ContentType;
11
import org.zalando.logbook.Correlation;
12
import org.zalando.logbook.HttpLogFormatter;
13
import org.zalando.logbook.HttpMessage;
14
import org.zalando.logbook.HttpRequest;
15
import org.zalando.logbook.HttpResponse;
16
import org.zalando.logbook.Precorrelation;
17

18
import java.io.IOException;
19
import java.io.StringWriter;
20
import java.util.List;
21
import java.util.Map;
22

23
import static org.apiguardian.api.API.Status.STABLE;
24

25
/**
26
 * A custom {@link HttpLogFormatter} that produces JSON objects.
27
 */
28
@API(status = STABLE)
29
@Slf4j
1✔
30
public final class FastJsonHttpLogFormatter implements HttpLogFormatter {
31

32
    private final JsonFactory factory;
33

34
    private final JsonFieldWriter delegate;
35
    
36
    private final boolean validateJsonBody;
37

38
    public FastJsonHttpLogFormatter() {
39
        this(new JsonMapper());
×
40
    }
×
41

42
    public FastJsonHttpLogFormatter(final JsonMapper mapper) {
43
        this(mapper, false);
1✔
44
    }
1✔
45

46
    public FastJsonHttpLogFormatter(final JsonMapper mapper, final boolean validateJsonBody) {
47
        this(mapper.tokenStreamFactory(), new DefaultJsonFieldWriter(mapper, validateJsonBody), validateJsonBody);
1✔
48
    }
1✔
49

50
    public FastJsonHttpLogFormatter(final JsonMapper mapper, final JsonFieldWriter writer) {
NEW
51
        this(mapper.tokenStreamFactory(), writer, false);
×
NEW
52
    }
×
53

54
    public FastJsonHttpLogFormatter(final JsonFactory factory, final JsonFieldWriter delegate) {
NEW
55
        this(factory, delegate, false);
×
NEW
56
    }
×
57

58
    public FastJsonHttpLogFormatter(final JsonFactory factory, final JsonFieldWriter delegate, final boolean validateJsonBody) {
1✔
59
        this.factory = factory;
1✔
60
        this.delegate = delegate;
1✔
61
        this.validateJsonBody = validateJsonBody;
1✔
62
    }
1✔
63

64
    @FunctionalInterface
65
    private interface Formatter<C extends Precorrelation, H extends HttpMessage> {
66
        void format(C correlation, H message, JsonGenerator generator) throws IOException;
67
    }
68

69
    @Override
70
    public String format(
71
            final Precorrelation precorrelation,
72
            final HttpRequest request) throws IOException {
73

74
        return format(precorrelation, request, delegate::write);
1✔
75
    }
76

77
    @Override
78
    public String format(
79
            final Correlation correlation,
80
            final HttpResponse response) throws IOException {
81

82
        return format(correlation, response, delegate::write);
×
83
    }
84

85
    private <C extends Precorrelation, H extends HttpMessage> String format(
86
            final C correlation,
87
            final H message,
88
            final Formatter<C, H> formatter) throws IOException {
89

90
        final StringWriter writer = new StringWriter(message.getBody().length + 2048);
1✔
91

92
        try (final JsonGenerator generator = factory.createGenerator(ObjectWriteContext.empty(), writer)) {
1✔
93
            generator.writeStartObject();
1✔
94
            formatter.format(correlation, message, generator);
1✔
95
            delegate.write(message, generator);
1✔
96
            generator.writeEndObject();
1✔
97
        }
98

99
        return writer.toString();
1✔
100
    }
101

102
    private static class DefaultJsonFieldWriter implements JsonFieldWriter {
103

104
        private final JsonMapper mapper;
105
        private final boolean validateJsonBody;
106

107
        DefaultJsonFieldWriter(final JsonMapper mapper) {
NEW
108
            this(mapper, false);
×
NEW
109
        }
×
110

111
        DefaultJsonFieldWriter(final JsonMapper mapper, final boolean validateJsonBody) {
1✔
112
            this.mapper = mapper;
1✔
113
            this.validateJsonBody = validateJsonBody;
1✔
114
        }
1✔
115

116
        @Override
117
        public <M extends HttpMessage> void write(M message, JsonGenerator generator) throws IOException {
118
            writeHeaders(message, generator);
1✔
119
            writeBody(message, generator);
1✔
120
        }
1✔
121

122
        private void writeHeaders(
123
                final HttpMessage message,
124
                final JsonGenerator generator) {
125

126
            final Map<String, List<String>> headers = message.getHeaders();
1✔
127

128
            if (headers.isEmpty()) {
1!
129
                return;
×
130
            }
131

132
            generator.writeName("headers");
1✔
133
            generator.writeStartObject();
1✔
134
            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
1✔
135
                generator.writeName(entry.getKey());
1✔
136
                generator.writeStartArray();
1✔
137
                for (String value : entry.getValue()) {
1✔
138
                    generator.writeString(value);
1✔
139
                }
1✔
140
                generator.writeEndArray();
1✔
141
            }
1✔
142
            generator.writeEndObject();
1✔
143
        }
1✔
144

145
        private void writeBody(
146
                final HttpMessage message,
147
                final JsonGenerator generator) throws IOException {
148

149
            final String body = message.getBodyAsString();
1✔
150

151
            if (body.isEmpty()) {
1!
152
                return;
×
153
            }
154
            generator.writeName("body");
1✔
155

156
            final String contentType = message.getContentType();
1✔
157

158
            if (ContentType.isJsonMediaType(contentType)) {
1!
159
                if (JsonUtil.looksLikeJson(body) && (!validateJsonBody || JsonUtil.isValidJson(body, mapper))) {
1!
160
                    generator.writeRawValue(body);
1✔
161
                } else {
NEW
162
                    generator.writeString(body);
×
163
                }
164
            } else {
165
                generator.writeString(body);
×
166
            }
167
        }
1✔
168
    }
169

170
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc