Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
? 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