diff --git a/src/main/java/ch/csnc/burp/CopyRequestResponseConfiguration.java b/src/main/java/ch/csnc/burp/CopyRequestResponseConfiguration.java index 334072b..21cda9b 100644 --- a/src/main/java/ch/csnc/burp/CopyRequestResponseConfiguration.java +++ b/src/main/java/ch/csnc/burp/CopyRequestResponseConfiguration.java @@ -20,6 +20,9 @@ public class CopyRequestResponseConfiguration { private static final boolean USE_NBSP_DEFAULT = false; private static final String TEMPLATE_LABEL = "Template"; private static final String TEMPLATE_DEFAULT = "{request}\\n\\n{response}"; + private static final String TEMPLATE_DESCRIPTION = + "Placeholders: {request}, {response}, {request:indent} (4 spaces), {response:indent=2} (n spaces), " + + "{request:indent=\\t} (literal prefix). Control characters: \\n, \\r, \\t, \\0 and \\\\."; private static final String HIDE_REQUEST_HEADERS_LABEL = "Hide Request Headers"; private static final String HIDE_REQUEST_HEADERS_DEFAULT = ""; private static final String HIDE_RESPONSE_HEADERS_LABEL = "Hide Response Headers"; @@ -31,7 +34,7 @@ public class CopyRequestResponseConfiguration { SettingsPanelSetting.stringSetting(COPY_FULL_FULL_OR_SELECTION_HOT_KEY_LABEL, COPY_FULL_FULL_OR_SELECTION_HOT_KEY_DEFAULT), SettingsPanelSetting.stringSetting(COPY_FULL_HEADER_LABEL, COPY_FULL_HEADER_DEFAULT), - SettingsPanelSetting.stringSetting(TEMPLATE_LABEL, TEMPLATE_DEFAULT), + SettingsPanelSetting.stringSetting(TEMPLATE_DESCRIPTION, TEMPLATE_LABEL, TEMPLATE_DEFAULT), SettingsPanelSetting.stringSetting( "Comma-separated, case-insensitive regexes to match header names, e.g., \"sec-.*,accept.*\"", HIDE_REQUEST_HEADERS_LABEL, HIDE_REQUEST_HEADERS_DEFAULT), diff --git a/src/main/java/ch/csnc/burp/CopyRequestResponseCopyActions.java b/src/main/java/ch/csnc/burp/CopyRequestResponseCopyActions.java index 85422e9..65043ca 100644 --- a/src/main/java/ch/csnc/burp/CopyRequestResponseCopyActions.java +++ b/src/main/java/ch/csnc/burp/CopyRequestResponseCopyActions.java @@ -8,10 +8,21 @@ import java.util.List; import java.util.Optional; import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; public class CopyRequestResponseCopyActions { + private static final int DEFAULT_INDENT_WIDTH = 4; + private static final int MAX_INDENT_WIDTH = 64; + + /** + * Matches {request}, {response}, {request:indent}, {response:indent=2}, + * {request:indent=\t} and alike. + */ + private static final Pattern PLACEHOLDER = Pattern.compile("\\{(request|response)(:indent(?:=([^}]*))?)?\\}"); + public static void copyFullFull(List requestResponses) { requestResponses = requestResponses.stream().map(CopyRequestResponseCopyActions::hideHeaders).toList(); @@ -153,10 +164,83 @@ private static HttpRequestResponse hideHeaders(HttpRequestResponse requestRespon } private static String format(String request, String response) { - return CopyRequestResponseConfiguration.template() - .replace("\\n", "\n") - .replace("{request}", request) - .replace("{response}", response); + // Control characters are expanded on the template only, never on the + // request/response payload itself. + var template = unescape(CopyRequestResponseConfiguration.template()); + + var matcher = PLACEHOLDER.matcher(template); + var text = new StringBuilder(); + while (matcher.find()) { + var value = "request".equals(matcher.group(1)) ? request : response; + if (matcher.group(2) != null) { + value = indent(value, indentPrefix(matcher.group(3))); + } + // quoteReplacement, so that a payload containing $ or \ is copied verbatim + // and a payload containing a placeholder is not substituted again. + matcher.appendReplacement(text, Matcher.quoteReplacement(value)); + } + matcher.appendTail(text); + + return text.toString(); + } + + /** + * Prefixes every non-empty line with the given prefix. Empty lines are kept + * empty, so that no trailing whitespace is copied. + */ + private static String indent(String text, String prefix) { + if (text.isEmpty()) { + return text; + } + return text.lines() + .map(line -> line.isEmpty() ? line : prefix + line) + .collect(Collectors.joining("\n")); + } + + /** + * Resolves the argument of an {@code :indent} placeholder: no argument means + * four spaces, a number means that many spaces, anything else is taken + * literally (control characters such as \t are expanded). + */ + private static String indentPrefix(String argument) { + String prefix; + if (argument == null || argument.isEmpty()) { + prefix = " ".repeat(DEFAULT_INDENT_WIDTH); + } else if (argument.chars().allMatch(Character::isDigit)) { + var width = argument.length() > 2 ? MAX_INDENT_WIDTH : Integer.parseInt(argument); + prefix = " ".repeat(Math.min(width, MAX_INDENT_WIDTH)); + } else { + prefix = unescape(argument); + } + if (CopyRequestResponseConfiguration.useNonBreakableSpace()) { + prefix = prefix.replace(' ', '\u00a0'); + } + return prefix; + } + + /** + * Expands the control characters \n, \r, \t, \0 and the escaped backslash \\. + * Unknown escape sequences are kept as they are. + */ + private static String unescape(String text) { + var unescaped = new StringBuilder(text.length()); + for (var index = 0; index < text.length(); index++) { + var character = text.charAt(index); + if (character != '\\' || index + 1 >= text.length()) { + unescaped.append(character); + continue; + } + var escaped = text.charAt(++index); + switch (escaped) { + case 'n' -> unescaped.append('\n'); + case 'r' -> unescaped.append('\r'); + case 't' -> unescaped.append('\t'); + case '0' -> unescaped.append('\0'); + case '\\' -> unescaped.append('\\'); + default -> unescaped.append('\\').append(escaped); + } + } + return unescaped.toString(); } private static void toClipboard(String text0) {