Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2ba9494
feat(scrubbing): add StringUrlSanitizer and DefaultUrlSanitizer
buongarzoni Jul 13, 2026
9049736
feat(scrubbing): add ScrubDataTransformer
buongarzoni Jul 13, 2026
7a9ca73
feat(config): expose redactedKeys and urlSanitizer
buongarzoni Jul 13, 2026
00dc4cb
feat(notifier): apply built-in scrubbing to every payload
buongarzoni Jul 13, 2026
bf3629d
refactor(okhttp): reuse the shared DefaultUrlSanitizer
buongarzoni Jul 13, 2026
9fee327
refactor(api): update imports
buongarzoni Jul 13, 2026
347ad48
refactor(scrubbing): update imports
buongarzoni Jul 13, 2026
306b105
refactor(config): update imports
buongarzoni Jul 13, 2026
52d5d40
refactor(okhttp): reuse the shared DefaultUrlSanitizer
buongarzoni Jul 13, 2026
82fb70b
fix(scrubbing): scrub Frame.locals in Body.rollbarThreads
buongarzoni Jul 13, 2026
8c06fd1
fix(scrubbing): match percent-encoded query parameter names
buongarzoni Jul 13, 2026
a09f485
fix(scrubbing): scrub Request.params and Request.metadata
buongarzoni Jul 13, 2026
57399ec
fix(scrubbing): traverse collections and arrays when scrubbing nested…
buongarzoni Aug 3, 2026
5482e72
fix(telemetry): sanitize URLs recorded as network telemetry events
buongarzoni Aug 3, 2026
f7bc6f3
test(scrubbing): cover ordering, reconfiguration and the okhttp sanit…
buongarzoni Aug 3, 2026
4c21b07
docs: add scrubbing documentation
buongarzoni Aug 10, 2026
1fc3ed6
feat(scrubbing): seed field scrubbing with a built-in key list
buongarzoni Aug 11, 2026
f016ac2
feat(config): expose useDefaultRedactedKeys
buongarzoni Aug 11, 2026
9ee5bc7
test(scrubbing): cover the built-in key list and the opt-out
buongarzoni Aug 11, 2026
f357b6c
test(scrubbing): prove secrets are redacted with no configuration
buongarzoni Aug 11, 2026
2ede510
docs: document the built-in redacted key list
buongarzoni Aug 11, 2026
0aa3993
perf(scrubbing): match literal keys without the regex engine
buongarzoni Aug 14, 2026
0c5a376
fix(scrubbing): redact Message.metadata in the body content + ignore …
buongarzoni Sep 14, 2026
01cd471
fix(scrubbing): redact Message.metadata in the body content + ignore …
buongarzoni Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.rollbar.api.scrubbing;

/**
* Default {@link StringUrlSanitizer} that strips userinfo, query string, and fragment from URLs.
* Uses string scanning rather than {@code java.net.URI} to avoid allocation on clean URLs
* and to preserve the original percent-encoding without normalization.
*/
public final class DefaultUrlSanitizer implements StringUrlSanitizer {

public static final DefaultUrlSanitizer INSTANCE = new DefaultUrlSanitizer();

private DefaultUrlSanitizer() {
}

@Override
public String sanitize(String url) {
if (url == null) {
return null;
}
// Fast path: no characters that can introduce query string, fragment, or userinfo.
if (url.indexOf('?') < 0 && url.indexOf('#') < 0 && url.indexOf('@') < 0) {
return url;
}
return strip(url);
}

private static String strip(String url) {
int end = url.length();
int q = url.indexOf('?');
int f = url.indexOf('#');
if (q >= 0 && q < end) {
end = q;
}
if (f >= 0 && f < end) {
end = f;
}
// Strip userinfo: find "://" then the last "@" before the first "/" after the authority start.
String result = url.substring(0, end);
int schemeEnd = result.indexOf("://");
if (schemeEnd >= 0) {
int hostStart = schemeEnd + 3;
int slashAfterHost = result.indexOf('/', hostStart);
int searchEnd = slashAfterHost < 0 ? result.length() : slashAfterHost;
int at = result.lastIndexOf('@', searchEnd);
if (at >= hostStart) {
result = result.substring(0, hostStart) + result.substring(at + 1);
}
}
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.rollbar.api.scrubbing;

/**
* Sanitizes a URL string before it is included in a Rollbar payload.
* Implementations should strip sensitive components such as userinfo,
* query parameters, and fragments.
*/
@FunctionalInterface
public interface StringUrlSanitizer {
/**
* Returns a sanitized version of the given URL string, or {@code null} if
* the input is {@code null}.
*
* @param url the raw URL string, may be {@code null}.
* @return the sanitized URL, or {@code null}.
*/
String sanitize(String url);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.rollbar.api.scrubbing;

import org.junit.Test;

import static org.junit.Assert.*;

public class DefaultUrlSanitizerTest {

private final DefaultUrlSanitizer sanitizer = DefaultUrlSanitizer.INSTANCE;

@Test
public void nullInputReturnsNull() {
assertNull(sanitizer.sanitize(null));
}

@Test
public void cleanUrlUnchanged() {
String url = "https://example.com/api/v1/things";
assertEquals(url, sanitizer.sanitize(url));
}

@Test
public void queryStringStripped() {
assertEquals(
"https://example.com/search",
sanitizer.sanitize("https://example.com/search?token=abc&page=1")
);
}

@Test
public void fragmentStripped() {
assertEquals(
"https://example.com/page",
sanitizer.sanitize("https://example.com/page#section")
);
}

@Test
public void userinfoStripped() {
assertEquals(
"https://example.com/path",
sanitizer.sanitize("https://user:pass@example.com/path")
);
}

@Test
public void allThreeScrubbed() {
assertEquals(
"https://example.com/path",
sanitizer.sanitize("https://admin:secret@example.com/path?token=xyz#top")
);
}

@Test
public void malformedUrlNoException() {
// Should not throw; best-effort strip
String result = sanitizer.sanitize("not-a-url?query=sensitive");
assertNotNull(result);
assertFalse(result.contains("sensitive"));
}

@Test
public void malformedUrlWithUserinfo() {
String result = sanitizer.sanitize("http://user:secret@host/path?q=1");
assertNotNull(result);
assertFalse(result.contains("secret"));
assertFalse(result.contains("q=1"));
}

@Test
public void emptyStringUnchanged() {
assertEquals("", sanitizer.sanitize(""));
}

@Test
public void cleanUrlReturnedAsSameInstance() {
String url = "https://example.com/api/v1/things";
assertSame(url, sanitizer.sanitize(url));
}

@Test
public void percentEncodedPathPreserved() {
// No ?, #, or @ — fast path must return the same instance without normalizing encoding.
String url = "https://example.com/path%20with%20spaces";
assertSame(url, sanitizer.sanitize(url));
}

@Test
public void atSignInPathNotTreatedAsUserinfo() {
// The @ is after the first path slash, so it is not userinfo.
String url = "https://example.com/users/@alice?token=x";
String result = sanitizer.sanitize(url);
assertTrue(result.contains("@alice"));
assertFalse(result.contains("token"));
}
}
11 changes: 11 additions & 0 deletions rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.rollbar.api.payload.data.body.Body;
import com.rollbar.jvmti.ThrowableCache;
import com.rollbar.notifier.config.CommonConfig;
import com.rollbar.notifier.scrubbing.ScrubDataTransformer;
import com.rollbar.notifier.telemetry.TelemetryEventTracker;
import com.rollbar.notifier.truncation.PayloadTruncator;
import com.rollbar.notifier.util.BodyFactory;
Expand Down Expand Up @@ -43,6 +44,8 @@ public abstract class RollbarBase<RESULT, C extends CommonConfig> {

protected C config;

private volatile ScrubDataTransformer builtInScrubber;

protected final ReadWriteLock configReadWriteLock = new ReentrantReadWriteLock();
protected final Lock configReadLock = configReadWriteLock.readLock();
protected final Lock configWriteLock = configReadWriteLock.writeLock();
Expand All @@ -55,6 +58,7 @@ protected RollbarBase(C config, BodyFactory bodyFactory, RESULT emptyResult) {
this.bodyFactory = bodyFactory;
this.emptyResult = emptyResult;
this.telemetryEventTracker = config.telemetryEventTracker();
this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer());
}

/**
Expand Down Expand Up @@ -123,6 +127,7 @@ protected void configure(C config) {
this.config = config;
configureTruncation(config);
processAppPackages(config);
this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer());
} finally {
this.configWriteLock.unlock();
}
Expand Down Expand Up @@ -250,10 +255,12 @@ protected Data buildData(CommonConfig config, ThrowableWrapper error, Map<String
protected RESULT process(ThrowableWrapper error, Map<String, Object> custom, String description,
Level level, boolean isUncaught) {
C config;
ScrubDataTransformer scrubber;

this.configReadLock.lock();
try {
config = this.config;
scrubber = this.builtInScrubber;
} finally {
this.configReadLock.unlock();
}
Expand All @@ -280,6 +287,10 @@ protected RESULT process(ThrowableWrapper error, Map<String, Object> custom, Str
data = config.transformer().transform(data);
}

// Built-in scrubbing always runs after the user transformer
LOGGER.debug("Applying built-in scrubber.");
data = scrubber.transform(data);

// Append if needed uuid or fingerprint data.
if (config.uuidGenerator() != null || config.fingerPrintGenerator() != null) {
Data.Builder dataBuilder = new Data.Builder(data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
import com.rollbar.api.payload.data.Person;
import com.rollbar.api.payload.data.Request;
import com.rollbar.api.payload.data.Server;
import com.rollbar.api.scrubbing.DefaultUrlSanitizer;
import com.rollbar.api.scrubbing.StringUrlSanitizer;
import com.rollbar.notifier.filter.Filter;
import com.rollbar.notifier.fingerprint.FingerprintGenerator;
import com.rollbar.notifier.provider.Provider;
import com.rollbar.notifier.sender.json.JsonSerializer;
import com.rollbar.notifier.telemetry.TelemetryEventTracker;
import com.rollbar.notifier.transformer.Transformer;
import com.rollbar.notifier.uuid.UuidGenerator;
import java.util.Collections;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -223,6 +226,29 @@ default boolean compressPayload() {
return true;
}

/**
* Keys (matched as case-insensitive regex) whose values should be redacted in headers,
* query/POST parameters, custom data, and {@code Frame.locals} before sending to Rollbar.
* The default header deny-list (Authorization, Cookie, etc.) is always applied regardless
* of this list.
*
* @return list of regex patterns; empty list by default.
*/
default List<String> redactedKeys() {
return Collections.emptyList();
}

/**
* URL sanitizer applied to {@link com.rollbar.api.payload.data.Request#getUrl()} before the
* payload is sent. Defaults to {@link DefaultUrlSanitizer#INSTANCE} which strips userinfo,
* query string, and fragment.
*
* @return the URL sanitizer; never {@code null}.
*/
default StringUrlSanitizer urlSanitizer() {
return DefaultUrlSanitizer.INSTANCE;
}

int maximumTelemetryData();

TelemetryEventTracker telemetryEventTracker();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.rollbar.api.payload.data.Person;
import com.rollbar.api.payload.data.Request;
import com.rollbar.api.payload.data.Server;
import com.rollbar.api.scrubbing.DefaultUrlSanitizer;
import com.rollbar.api.scrubbing.StringUrlSanitizer;
import com.rollbar.notifier.Rollbar;
import com.rollbar.notifier.filter.Filter;
import com.rollbar.notifier.fingerprint.FingerprintGenerator;
Expand Down Expand Up @@ -89,6 +91,10 @@ public class ConfigBuilder {

protected boolean compressPayload;

protected List<String> redactedKeys;

protected StringUrlSanitizer urlSanitizer;

private int maximumTelemetryData =
RollbarTelemetryEventTracker.MAXIMUM_CAPACITY_FOR_TELEMETRY_EVENTS;

Expand Down Expand Up @@ -142,6 +148,8 @@ private ConfigBuilder(Config config) {
this.compressPayload = config.compressPayload();
this.maximumTelemetryData = config.maximumTelemetryData();
this.telemetryEventTracker = config.telemetryEventTracker();
this.redactedKeys = config.redactedKeys();
this.urlSanitizer = config.urlSanitizer();
}

/**
Expand Down Expand Up @@ -523,6 +531,31 @@ public ConfigBuilder telemetryEventTracker(TelemetryEventTracker telemetryEventT
return this;
}

/**
* Keys (matched as case-insensitive regex) whose values will be redacted in request headers,
* query/POST parameters, custom data, and {@code Frame.locals}. These are additive to the
* built-in header deny-list (Authorization, Cookie, etc.).
*
* @param redactedKeys list of regex patterns.
* @return the builder instance.
*/
public ConfigBuilder redactedKeys(List<String> redactedKeys) {
this.redactedKeys = redactedKeys;
return this;
}

/**
* URL sanitizer applied to the request URL before the payload is sent.
* Defaults to {@link DefaultUrlSanitizer#INSTANCE}.
*
* @param urlSanitizer the sanitizer.
* @return the builder instance.
*/
public ConfigBuilder urlSanitizer(StringUrlSanitizer urlSanitizer) {
this.urlSanitizer = urlSanitizer;
return this;
}

/**
* Builds the {@link Config config}.
*
Expand Down Expand Up @@ -624,6 +657,10 @@ private static class ConfigImpl implements Config {

private final TelemetryEventTracker telemetryEventTracker;

private final List<String> redactedKeys;

private final StringUrlSanitizer urlSanitizer;

ConfigImpl(ConfigBuilder builder) {
this.accessToken = builder.accessToken;
this.endpoint = builder.endpoint;
Expand Down Expand Up @@ -659,6 +696,10 @@ private static class ConfigImpl implements Config {
this.compressPayload = builder.compressPayload;
this.maximumTelemetryData = builder.maximumTelemetryData;
this.telemetryEventTracker = builder.telemetryEventTracker;
this.redactedKeys = builder.redactedKeys != null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Seed field scrubbing with a meaningful default list

When redactedKeys is unset, this stores an empty list, so ScrubDataTransformer leaves GET/POST parameters, request.query_string, custom data, metadata, and Frame.locals untouched. Stripping the query from request.url does not close that gap: the web request provider serializes parsed GET parameters and the raw query string separately, so /login?password=hunter2 still sends hunter2 under both request.get.password and request.query_string with the default configuration.

That means the default behavior only protects the built-in header deny-list and URL strings; it does not satisfy the stated goal that sensitive data is redacted by default. Please seed this with an additive built-in field list (for example password/passwd, secret, token/access_token, auth/authentication/authorization), ideally with an explicit override mechanism, and add a no-configuration end-to-end test proving the secret is absent from every relevant request representation.

? builder.redactedKeys : Collections.<String>emptyList();
this.urlSanitizer = builder.urlSanitizer != null
? builder.urlSanitizer : DefaultUrlSanitizer.INSTANCE;
}

@Override
Expand Down Expand Up @@ -820,5 +861,15 @@ public int maximumTelemetryData() {
public TelemetryEventTracker telemetryEventTracker() {
return this.telemetryEventTracker;
}

@Override
public List<String> redactedKeys() {
return redactedKeys;
}

@Override
public StringUrlSanitizer urlSanitizer() {
return urlSanitizer;
}
}
}
Loading
Loading