diff --git a/build.gradle.kts b/build.gradle.kts index a8123aa..8341dc0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { group = "com.specops" -version = "1.4.0" +version = "1.4.1" repositories { mavenCentral() diff --git a/src/main/java/com/specops/services/openapi/OpenApiParser.java b/src/main/java/com/specops/services/openapi/OpenApiParser.java index e0cf5ce..69d128f 100644 --- a/src/main/java/com/specops/services/openapi/OpenApiParser.java +++ b/src/main/java/com/specops/services/openapi/OpenApiParser.java @@ -5,6 +5,9 @@ import com.specops.domain.Parameter; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.RequestBody; import io.swagger.v3.parser.OpenAPIV3Parser; @@ -37,14 +40,47 @@ private static boolean looksLikeSwagger2(String specContent, List messag return (i >= 0 && i < 4096) || (j >= 0 && j < 4096); } + /** A non-empty value supplied by a specification that can flow into a generated request. */ + public record AutomaticValue( + String endpoint, + String location, + String parameter, + String source, + String value) { + + public AutomaticValue { + endpoint = Objects.requireNonNullElse(endpoint, ""); + location = Objects.requireNonNullElse(location, ""); + parameter = Objects.requireNonNullElse(parameter, ""); + source = Objects.requireNonNullElse(source, ""); + value = Objects.requireNonNullElse(value, ""); + } + } + + /** A successfully parsed model that has not yet been applied to the shared context. */ + public record ParsedSpecification( + OpenAPI openAPI, + List endpoints, + Map parameters, + List automaticValues) { + + public ParsedSpecification { + Objects.requireNonNull(openAPI, "openAPI"); + endpoints = List.copyOf(endpoints); + parameters = Map.copyOf(parameters); + automaticValues = List.copyOf(automaticValues); + } + } + /** * Parses the given spec content. Tries OAS3 first, then falls back to Swagger 2.0 - * with in-memory conversion to OAS3. On success, updates the context model. + * with in-memory conversion to OAS3. The result is staged: parsing never replaces + * the shared model or parameter store. */ - public boolean parse(String specContent) { + public Optional parse(String specContent) { if (specContent == null || specContent.trim().isEmpty()) { context.api.logging().logToError("Failed to parse OpenAPI specification. Issues: empty content"); - return false; + return Optional.empty(); } ParseOptions options = new ParseOptions(); @@ -85,7 +121,7 @@ public boolean parse(String specContent) { String errorMessage = "Failed to parse OpenAPI specification."; if (!messages.isEmpty()) errorMessage += " Issues: " + String.join(", ", messages); context.api.logging().logToError(errorMessage); - return false; + return Optional.empty(); } final OpenAPI oas = openAPI; @@ -101,6 +137,7 @@ public boolean parse(String specContent) { // Build data model List endpoints = new ArrayList<>(); Map parameters = new HashMap<>(); + Set automaticValues = new LinkedHashSet<>(); oas.getPaths().forEach((path, pathItem) -> { if (pathItem == null) return; @@ -108,7 +145,11 @@ public boolean parse(String specContent) { pathItem.readOperationsMap().forEach((method, operation) -> { if (operation == null) return; - endpoints.add(new Endpoint(path, method, operation)); + Endpoint endpoint = new Endpoint(path, method, operation); + endpoints.add(endpoint); + + collectEffectiveParameterValues(oas, path, method, pathItem, operation, automaticValues); + collectRequestBodyValues(oas, path, method, operation, automaticValues); // classic parameter locations if (pathItem.getParameters() != null) { @@ -130,18 +171,276 @@ public boolean parse(String specContent) { }); }); - context.resetModel(oas, endpoints, parameters); - return true; + collectStoredParameterValues(parameters, automaticValues); + + return Optional.of(new ParsedSpecification( + oas, + endpoints, + parameters, + new ArrayList<>(automaticValues) + )); + } + + private void collectEffectiveParameterValues( + OpenAPI openAPI, + String path, + PathItem.HttpMethod method, + PathItem pathItem, + Operation operation, + Set out) { + + Map effective = new LinkedHashMap<>(); + addEffectiveParameters(openAPI, effective, pathItem.getParameters()); + addEffectiveParameters(openAPI, effective, operation.getParameters()); + + String endpoint = endpointLabel(method, path); + for (io.swagger.v3.oas.models.parameters.Parameter parameter : effective.values()) { + String location = Objects.requireNonNullElse(parameter.getIn(), "parameter"); + String name = Objects.requireNonNullElse(parameter.getName(), "(unnamed)"); + + addAutomaticValue(out, endpoint, location, name, + "parameter example", parameter.getExample()); + collectNamedExamples(openAPI, out, endpoint, location, name, + "parameter example", parameter.getExamples()); + + Schema schema = derefSchema(openAPI, parameter.getSchema()); + if (schema != null) { + addSchemaValues(out, endpoint, location, name, schema); + } + } + } + + private void addEffectiveParameters( + OpenAPI openAPI, + Map effective, + List candidates) { + + if (candidates == null) return; + for (io.swagger.v3.oas.models.parameters.Parameter candidate : candidates) { + io.swagger.v3.oas.models.parameters.Parameter parameter = derefParam(candidate, openAPI); + if (parameter == null || parameter.getName() == null || parameter.getIn() == null) continue; + effective.put(parameter.getIn() + "|" + parameter.getName(), parameter); + } + } + + private void collectRequestBodyValues( + OpenAPI openAPI, + String path, + PathItem.HttpMethod method, + Operation operation, + Set out) { + + RequestBody requestBody = derefRequestBody(openAPI, operation.getRequestBody()); + if (requestBody == null || requestBody.getContent() == null) return; + + String endpoint = endpointLabel(method, path); + for (Map.Entry entry : requestBody.getContent().entrySet()) { + String mediaName = Objects.requireNonNullElse(entry.getKey(), "(unspecified media type)"); + MediaType media = entry.getValue(); + if (media == null) continue; + + addAutomaticValue(out, endpoint, "body", mediaName + " $", + "media example", media.getExample()); + collectNamedExamples(openAPI, out, endpoint, "body", mediaName + " $", + "media example", media.getExamples()); + + collectSchemaValues( + openAPI, + out, + endpoint, + "body", + mediaName, + "$", + media.getSchema(), + Collections.newSetFromMap(new IdentityHashMap<>()), + 0 + ); + } + } + + private void collectNamedExamples( + OpenAPI openAPI, + Set out, + String endpoint, + String location, + String parameter, + String source, + Map examples) { + + if (examples == null) return; + examples.forEach((name, unresolved) -> { + Example example = derefExample(openAPI, unresolved); + if (example != null) { + addAutomaticValue(out, endpoint, location, parameter, + source + " " + Objects.requireNonNullElse(name, "(unnamed)"), example.getValue()); + } + }); + } + + private void collectSchemaValues( + OpenAPI openAPI, + Set out, + String endpoint, + String location, + String mediaName, + String path, + Schema unresolved, + Set> activeSchemas, + int depth) { + + if (unresolved == null || depth > MAX_SCHEMA_DEPTH) return; + Schema schema = derefSchema(openAPI, unresolved); + if (schema == null || !activeSchemas.add(schema)) return; + + try { + addSchemaValues(out, endpoint, location, mediaName + " " + path, schema); + + if (schema.getProperties() != null) { + schema.getProperties().forEach((name, child) -> collectSchemaValues( + openAPI, + out, + endpoint, + location, + mediaName, + appendPropertyPath(path, name), + child, + activeSchemas, + depth + 1 + )); + } + if (schema.getItems() != null) { + collectSchemaValues(openAPI, out, endpoint, location, mediaName, + path + "[]", schema.getItems(), activeSchemas, depth + 1); + } + if (schema.getAdditionalProperties() instanceof Schema additionalSchema) { + collectSchemaValues(openAPI, out, endpoint, location, mediaName, + appendPropertyPath(path, "{*}"), additionalSchema, activeSchemas, depth + 1); + } + + collectComposedSchemaValues(openAPI, out, endpoint, location, mediaName, path, + "allOf", schema.getAllOf(), activeSchemas, depth); + collectComposedSchemaValues(openAPI, out, endpoint, location, mediaName, path, + "oneOf", schema.getOneOf(), activeSchemas, depth); + collectComposedSchemaValues(openAPI, out, endpoint, location, mediaName, path, + "anyOf", schema.getAnyOf(), activeSchemas, depth); + } finally { + activeSchemas.remove(schema); + } + } + + private void collectComposedSchemaValues( + OpenAPI openAPI, + Set out, + String endpoint, + String location, + String mediaName, + String path, + String composition, + List schemas, + Set> activeSchemas, + int depth) { + + if (schemas == null) return; + for (int i = 0; i < schemas.size(); i++) { + collectSchemaValues(openAPI, out, endpoint, location, mediaName, + path + " (" + composition + " " + (i + 1) + ")", + schemas.get(i), activeSchemas, depth + 1); + } + } + + private static void addSchemaValues( + Set out, + String endpoint, + String location, + String parameter, + Schema schema) { + + addAutomaticValue(out, endpoint, location, parameter, "schema default", schema.getDefault()); + addAutomaticValue(out, endpoint, location, parameter, "schema example", schema.getExample()); + if (schema.getEnum() != null && !schema.getEnum().isEmpty()) { + addAutomaticValue(out, endpoint, location, parameter, "first enum", schema.getEnum().get(0)); + } + } + + private static void addAutomaticValue( + Set out, + String endpoint, + String location, + String parameter, + String source, + Object rawValue) { + + if (rawValue == null) return; + String value = String.valueOf(rawValue); + if (!value.isEmpty()) { + out.add(new AutomaticValue(endpoint, location, parameter, source, value)); + } + } + + private static void collectStoredParameterValues( + Map parameters, + Set out) { + for (Parameter parameter : parameters.values()) { + if (parameter == null || !parameter.hasValue()) continue; + + String source; + if (!parameter.getDefaultValue().isEmpty()) { + source = "stored schema default"; + } else if (!parameter.getExampleValue().isEmpty()) { + source = "stored example"; + } else if (parameter.hasEnum()) { + source = "stored first enum"; + } else { + source = "stored parser value"; + } + + String name = "body".equalsIgnoreCase(parameter.getIn()) + && !parameter.getJsonPath().isEmpty() + ? parameter.getJsonPath() + : parameter.getName(); + addAutomaticValue( + out, + "(global parameter store)", + parameter.getIn(), + name, + source, + parameter.getValue() + ); + } + } + + private static String appendPropertyPath(String path, String property) { + return "$".equals(path) ? "$." + property : path + "." + property; + } + + private static String endpointLabel(PathItem.HttpMethod method, String path) { + return (method == null ? "" : method.name()) + " " + Objects.requireNonNullElse(path, ""); + } + + private Example derefExample(OpenAPI openAPI, Example example) { + if (example == null || openAPI.getComponents() == null + || openAPI.getComponents().getExamples() == null) return example; + Set visited = new HashSet<>(); + while (example.get$ref() != null && visited.add(example.get$ref())) { + String name = example.get$ref().substring(example.get$ref().lastIndexOf('/') + 1); + Example resolved = openAPI.getComponents().getExamples().get(name); + if (resolved == null || resolved == example) break; + example = resolved; + } + return example; } private io.swagger.v3.oas.models.parameters.Parameter derefParam( io.swagger.v3.oas.models.parameters.Parameter p, OpenAPI openAPI) { - if (p == null || p.get$ref() == null) return p; - String name = p.get$ref().substring(p.get$ref().lastIndexOf('/') + 1); - if (openAPI.getComponents() != null && openAPI.getComponents().getParameters() != null) { + if (p == null || openAPI.getComponents() == null + || openAPI.getComponents().getParameters() == null) return p; + Set visited = new HashSet<>(); + while (p.get$ref() != null && visited.add(p.get$ref())) { + String name = p.get$ref().substring(p.get$ref().lastIndexOf('/') + 1); io.swagger.v3.oas.models.parameters.Parameter resolved = openAPI.getComponents().getParameters().get(name); - return resolved != null ? resolved : p; + if (resolved == null || resolved == p) break; + p = resolved; } return p; } @@ -168,7 +467,7 @@ private void addParameterToMap(io.swagger.v3.oas.models.parameters.Parameter par p.setEnumValues(enums); } } - if (p.getExampleValue() == null && parsedParam.getExample() != null) { + if (p.getExampleValue().isEmpty() && parsedParam.getExample() != null) { p.setExampleValue(String.valueOf(parsedParam.getExample())); } @@ -220,21 +519,29 @@ private void indexRequestBodyParams(OpenAPI oas, Operation op, Map visited = new HashSet<>(); + while (rb.get$ref() != null && visited.add(rb.get$ref())) { + String name = rb.get$ref().substring(rb.get$ref().lastIndexOf('/') + 1); + RequestBody resolved = oas.getComponents().getRequestBodies().get(name); + if (resolved == null || resolved == rb) break; + rb = resolved; + } + return rb; } private Schema derefSchema(OpenAPI oas, Schema s) { - if (s == null || s.get$ref() == null) return s; - String name = s.get$ref().substring(s.get$ref().lastIndexOf('/') + 1); - return oas.getComponents() != null - && oas.getComponents().getSchemas() != null - ? oas.getComponents().getSchemas().getOrDefault(name, s) - : s; + if (s == null || oas.getComponents() == null + || oas.getComponents().getSchemas() == null) return s; + Set visited = new HashSet<>(); + while (s.get$ref() != null && visited.add(s.get$ref())) { + String name = s.get$ref().substring(s.get$ref().lastIndexOf('/') + 1); + Schema resolved = oas.getComponents().getSchemas().get(name); + if (resolved == null || resolved == s) break; + s = resolved; + } + return s; } private String normalizeMediaKey(String mt) { diff --git a/src/main/java/com/specops/services/request/RequestFactory.java b/src/main/java/com/specops/services/request/RequestFactory.java index 420d0d8..f56bb94 100644 --- a/src/main/java/com/specops/services/request/RequestFactory.java +++ b/src/main/java/com/specops/services/request/RequestFactory.java @@ -39,6 +39,29 @@ public RequestFactory(SpecOpsContext context) { this.context = context; } + /** + * Strip CR/LF from a header name. A malicious spec can supply a header + * parameter (or security-scheme) whose name carries CRLF; without this the + * newline injects into the generated request. Note that any reserved-header + * guard MUST run on the sanitized name, otherwise a name such as "Ho\nst" + * evades the guard and then collapses onto a protected header. + */ + private static String sanitizeHeaderName(String name) { + return name == null ? null : name.replace("\r", "").replace("\n", ""); + } + + /** Headers that are managed by the request builder / transport and must never be set from a spec. */ + private static boolean isReservedHeaderName(String nameLc) { + return nameLc.equals("host") || nameLc.equals("content-length") + || nameLc.equals("transfer-encoding") || nameLc.equals("connection"); + } + + private static HttpHeader sanitizedHeader(String name, String value) { + String sanitizedName = sanitizeHeaderName(name); + String sanitizedValue = value == null ? null : value.replace("\r", "").replace("\n", ""); + return httpHeader(sanitizedName, sanitizedValue); + } + public HttpRequest buildRequest(Endpoint endpoint) { Map paramStore = context.getGlobalParameterStore(); @@ -104,7 +127,7 @@ public HttpRequest buildRequest(Endpoint endpoint) { if ((secure && port != 443) || (!secure && port != 80)) { hostValue = hostValue + ":" + port; } - headers.add(httpHeader("Host", hostValue)); + headers.add(sanitizedHeader("Host", hostValue)); } // Body from OpenAPI examples or synthesized from schema, then overlay global store overrides for in="body" @@ -171,9 +194,11 @@ public HttpRequest buildRequest(Endpoint endpoint) { } if (!match) continue; - // Guardrails for dangerous or managed headers - String nameLc = r.name.toLowerCase(Locale.ROOT); - if (nameLc.equals("host") || nameLc.equals("content-length") || nameLc.equals("transfer-encoding") || nameLc.equals("connection")) { + // Guardrails for dangerous or managed headers. Guard on the + // sanitized name so a CRLF-laced rule name cannot evade it. + String ruleName = sanitizeHeaderName(r.name); + String nameLc = ruleName == null ? "" : ruleName.toLowerCase(Locale.ROOT); + if (isReservedHeaderName(nameLc)) { continue; } @@ -193,12 +218,12 @@ public HttpRequest buildRequest(Endpoint endpoint) { } } - boolean exists = headers.stream().anyMatch(h -> h.name().equalsIgnoreCase(r.name)); + boolean exists = headers.stream().anyMatch(h -> h.name().equalsIgnoreCase(ruleName)); if (exists && r.overwrite) { - headers.removeIf(h -> h.name().equalsIgnoreCase(r.name)); - headers.add(httpHeader(r.name, value)); + headers.removeIf(h -> h.name().equalsIgnoreCase(ruleName)); + headers.add(sanitizedHeader(ruleName, value)); } else if (!exists) { - headers.add(httpHeader(r.name, value)); + headers.add(sanitizedHeader(ruleName, value)); } } } @@ -333,8 +358,14 @@ private void addHeadersFromStore(List headers, Endpoint endpoint, Ma Parameter stored = findParam(store, "header", specParam.getName()); String value = stored != null ? stored.getValue() : null; - String nlc = specParam.getName() != null ? specParam.getName().toLowerCase(Locale.ROOT) : ""; - if (nlc.equals("host") || nlc.equals("content-length") || nlc.equals("transfer-encoding") || nlc.equals("connection")) { + // Sanitize the name BEFORE the reserved-header guard so a CRLF-laced + // name (e.g. "Ho\nst") cannot slip past the guard and then collapse + // onto Host / Content-Length / Transfer-Encoding / Connection. + String name = sanitizeHeaderName(specParam.getName()); + String nlc = name != null ? name.toLowerCase(Locale.ROOT) : ""; + // Reject names that are empty (or collapse to empty after sanitizing, + // e.g. a param literally named "\r\n") or reserved before emitting. + if (name == null || name.isEmpty() || isReservedHeaderName(nlc)) { continue; } @@ -348,9 +379,9 @@ private void addHeadersFromStore(List headers, Endpoint endpoint, Ma } if (value != null && !value.isEmpty()) { - boolean isCT = "content-type".equalsIgnoreCase(specParam.getName()); + boolean isCT = "content-type".equals(nlc); boolean alreadyHasCT = isCT && headers.stream().anyMatch(h -> h.name().equalsIgnoreCase("Content-Type")); - if (!alreadyHasCT) headers.add(httpHeader(specParam.getName(), value)); + if (!alreadyHasCT) headers.add(sanitizedHeader(name, value)); } } } @@ -385,7 +416,7 @@ private void addCookieHeader(List headers, Endpoint endpoint, Map h.name().equalsIgnoreCase("Cookie")); - headers.add(httpHeader("Cookie", combined)); + headers.add(sanitizedHeader("Cookie", combined)); } } @@ -548,8 +579,7 @@ private boolean tryInjectOneRequirement(List headers, String name = scheme.getName() != null ? scheme.getName() : "api_key"; if (in == SecurityScheme.In.HEADER) { - upsertHeader(headers, name, token); - didInject = true; + didInject = upsertHeader(headers, name, token) || didInject; } else if (in == SecurityScheme.In.COOKIE) { upsertCookieKV(headers, name, token); didInject = true; @@ -559,17 +589,14 @@ private boolean tryInjectOneRequirement(List headers, case HTTP -> { String schemeNameLc = scheme.getScheme() == null ? "" : scheme.getScheme().toLowerCase(Locale.ROOT); if ("bearer".equals(schemeNameLc)) { - upsertHeader(headers, "Authorization", "Bearer " + token); - didInject = true; + didInject = upsertHeader(headers, "Authorization", "Bearer " + token) || didInject; } else if ("basic".equals(schemeNameLc)) { - upsertHeader(headers, "Authorization", "Basic " + token); - didInject = true; + didInject = upsertHeader(headers, "Authorization", "Basic " + token) || didInject; } } case OAUTH2, OPENIDCONNECT -> { - upsertHeader(headers, "Authorization", "Bearer " + token); - didInject = true; + didInject = upsertHeader(headers, "Authorization", "Bearer " + token) || didInject; } default -> { @@ -581,9 +608,23 @@ private boolean tryInjectOneRequirement(List headers, } // helpers: upsert header and cookie KV - private void upsertHeader(List headers, String name, String value) { - headers.removeIf(h -> h.name().equalsIgnoreCase(name)); - headers.add(httpHeader(name, value)); + + /** + * Insert (replacing any existing header of the same name) a header, unless the + * name is empty or reserved once sanitized. Returns {@code true} only when a + * header was actually inserted, so callers can track whether authentication + * was really applied (a rejected name must not mark an OR requirement as met). + */ + private boolean upsertHeader(List headers, String name, String value) { + // Sanitize first, then refuse reserved headers: a spec-controlled + // security-scheme name (e.g. an apiKey named "Host" or "Ho\nst") must + // not be able to inject or suppress a transport-managed header. + String sanitizedName = sanitizeHeaderName(name); + if (sanitizedName == null || sanitizedName.isEmpty()) return false; + if (isReservedHeaderName(sanitizedName.toLowerCase(Locale.ROOT))) return false; + headers.removeIf(h -> h.name().equalsIgnoreCase(sanitizedName)); + headers.add(sanitizedHeader(sanitizedName, value)); + return true; } private void upsertCookieKV(List headers, String cookieName, String cookieValue) { @@ -610,7 +651,7 @@ private void upsertCookieKV(List headers, String cookieName, String .map(e -> e.getKey() + "=" + e.getValue()) .collect(java.util.stream.Collectors.joining("; ")); headers.removeIf(h -> h.name().equalsIgnoreCase("Cookie")); - headers.add(httpHeader("Cookie", joined)); + headers.add(sanitizedHeader("Cookie", joined)); } private java.util.Set getAuthQueryParamNamesForEndpoint(Endpoint endpoint) { @@ -688,22 +729,22 @@ private BuiltBody buildBodyFromSpecExamples(RequestBody rb, if (norm.equals("application/x-www-form-urlencoded")) { Map m = materializeMapFromSchema(schema, 0); applyBodyOverridesToMap(m, store, ""); - return new BuiltBody(renderWwwForm(m), List.of(httpHeader("Content-Type", headerKey))); + return new BuiltBody(renderWwwForm(m), List.of(sanitizedHeader("Content-Type", headerKey))); } else if (norm.equals("multipart/form-data")) { Map m = materializeMapFromSchema(schema, 0); applyBodyOverridesToMap(m, store, ""); String boundary = "----SpecOps" + UUID.randomUUID(); String body = renderMultipart(m, boundary, media.getEncoding()) + "\r\n"; - return new BuiltBody(body, List.of(httpHeader("Content-Type", headerKey + "; boundary=" + boundary))); + return new BuiltBody(body, List.of(sanitizedHeader("Content-Type", headerKey + "; boundary=" + boundary))); } else if (isJsonLike(norm) || norm.equals("*/*") || norm.isEmpty()) { String json = materializeJsonFromSchema(schema, 0); json = applyBodyOverridesToJson(json, store, schema); String ctHeader = isJsonLike(norm) ? headerKey : "application/json"; - return new BuiltBody(json, List.of(httpHeader("Content-Type", ctHeader))); + return new BuiltBody(json, List.of(sanitizedHeader("Content-Type", ctHeader))); } else { String json = materializeJsonFromSchema(schema, 0); json = applyBodyOverridesToJson(json, store, schema); - return new BuiltBody(json, List.of(httpHeader("Content-Type", "application/json"))); + return new BuiltBody(json, List.of(sanitizedHeader("Content-Type", "application/json"))); } } @@ -712,28 +753,28 @@ private BuiltBody buildBodyFromSpecExamples(RequestBody rb, if (norm.equals("application/x-www-form-urlencoded")) { Map m = coerceToMap(example); applyBodyOverridesToMap(m, store, ""); - return new BuiltBody(renderWwwForm(m), List.of(httpHeader("Content-Type", headerKey))); + return new BuiltBody(renderWwwForm(m), List.of(sanitizedHeader("Content-Type", headerKey))); } else if (norm.equals("multipart/form-data")) { Map m = coerceToMap(example); applyBodyOverridesToMap(m, store, ""); String boundary = "----SpecOps" + UUID.randomUUID(); String body = renderMultipart(m, boundary, media.getEncoding()) + "\r\n"; - return new BuiltBody(body, List.of(httpHeader("Content-Type", headerKey + "; boundary=" + boundary))); + return new BuiltBody(body, List.of(sanitizedHeader("Content-Type", headerKey + "; boundary=" + boundary))); } else if (isJsonLike(norm) || norm.equals("*/*") || norm.isEmpty()) { String ctHeader = isJsonLike(norm) ? headerKey : "application/json"; String json = renderJson(example); json = applyBodyOverridesToJson(json, store, schema); - return new BuiltBody(json, List.of(httpHeader("Content-Type", ctHeader))); + return new BuiltBody(json, List.of(sanitizedHeader("Content-Type", ctHeader))); } else if (norm.startsWith("text/")) { String s = String.valueOf(example); String rootOverride = readBodyOverride(store, ""); if (rootOverride != null) s = rootOverride; - return new BuiltBody(s, List.of(httpHeader("Content-Type", headerKey))); + return new BuiltBody(s, List.of(sanitizedHeader("Content-Type", headerKey))); } else { String s = String.valueOf(example); String rootOverride = readBodyOverride(store, ""); if (rootOverride != null) s = rootOverride; - return new BuiltBody(s, List.of(httpHeader("Content-Type", headerKey))); + return new BuiltBody(s, List.of(sanitizedHeader("Content-Type", headerKey))); } } } @@ -744,7 +785,7 @@ private BuiltBody buildBodyFromSpecExamples(RequestBody rb, if (schema != null) { String json = materializeJsonFromSchema(schema, 0); json = applyBodyOverridesToJson(json, store, schema); - return new BuiltBody(json, List.of(httpHeader("Content-Type", "application/json"))); + return new BuiltBody(json, List.of(sanitizedHeader("Content-Type", "application/json"))); } } @@ -1449,7 +1490,7 @@ public List buildRequestsForBulkSend(Endpoint endpoint) { if ((secure && port != 443) || (!secure && port != 80)) { hostValue = hostValue + ":" + port; } - newHeaders.add(httpHeader("Host", hostValue)); + newHeaders.add(sanitizedHeader("Host", hostValue)); HttpRequest copy = HttpRequest.httpRequest() .withService(svc) diff --git a/src/main/java/com/specops/ui/EndpointsTab.java b/src/main/java/com/specops/ui/EndpointsTab.java index 4dc577f..27eb28c 100644 --- a/src/main/java/com/specops/ui/EndpointsTab.java +++ b/src/main/java/com/specops/ui/EndpointsTab.java @@ -29,6 +29,8 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -61,6 +63,7 @@ public class EndpointsTab extends JPanel { private final AtomicBoolean pauseRequested = new AtomicBoolean(false); private final AtomicBoolean stopAfterCurrent = new AtomicBoolean(false); + private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); private final Object pauseLock = new Object(); public EndpointsTab(SpecOpsContext context, JTabbedPane mainPane) { @@ -438,46 +441,51 @@ private void pingSelectedEndpointsWorker() { activeWorker = new SwingWorker<>() { @Override - protected Void doInBackground() { - int done = 0; - AtomicInteger completedCount = new AtomicInteger(0); - - for (Map.Entry> entry : plan.entrySet()) { - if (stopAfterCurrent.get()) break; - - Endpoint endpoint = entry.getKey(); - List requests = entry.getValue(); - if (requests == null || requests.isEmpty()) { - continue; - } + protected Void doInBackground() throws Exception { + try { + int done = 0; + AtomicInteger completedCount = new AtomicInteger(0); - for (HttpRequest request : requests) { - waitIfPaused(); + for (Map.Entry> entry : plan.entrySet()) { if (stopAfterCurrent.get()) break; - try { - var requestResponse = context.api.http().sendRequest(request); - String timestamp = new SimpleDateFormat("HH:mm:ss").format(new Date()); - HttpResponse response = requestResponse.response(); - HttpRequest finalRequest = requestResponse.request(); - - publish(new AttackResult(endpoint, finalRequest, response, timestamp)); - } catch (Throwable t) { - String timestamp = new SimpleDateFormat("HH:mm:ss").format(new Date()); - publish(new AttackResult(endpoint, request, null, timestamp)); + Endpoint endpoint = entry.getKey(); + List requests = entry.getValue(); + if (requests == null || requests.isEmpty()) { + continue; } - done++; - completedCount.set(done); - int pct = (int) Math.round((done * 100.0) / totalCount); - setProgress(pct); - updateCountAndEtaOnEDT(done, totalCount, startNano); + for (HttpRequest request : requests) { + waitIfPaused(); + if (stopAfterCurrent.get()) break; + + try { + var requestResponse = context.api.http().sendRequest(request); + String timestamp = new SimpleDateFormat("HH:mm:ss").format(new Date()); + HttpResponse response = requestResponse.response(); + HttpRequest finalRequest = requestResponse.request(); + + publish(new AttackResult(endpoint, finalRequest, response, timestamp)); + } catch (Throwable t) { + String timestamp = new SimpleDateFormat("HH:mm:ss").format(new Date()); + publish(new AttackResult(endpoint, request, null, timestamp)); + } + + done++; + completedCount.set(done); + int pct = (int) Math.round((done * 100.0) / totalCount); + setProgress(pct); + updateCountAndEtaOnEDT(done, totalCount, startNano); + } } + if (stopAfterCurrent.get() && completedCount.get() < totalCount) { + runOnEdt(() -> statusLabel.setText("Status: Stopping")); + } + return null; + } catch (Exception | Error failure) { + context.api.logging().logToError("Unexpected failure while pinging endpoints.", failure); + throw failure; } - if (stopAfterCurrent.get() && completedCount.get() < totalCount) { - runOnEdt(() -> statusLabel.setText("Status: Stopping")); - } - return null; } @Override @@ -489,26 +497,61 @@ protected void process(List chunks) { @Override protected void done() { - btnPing.setEnabled(true); - btnCancel.setEnabled(false); - btnPause.setEnabled(false); - btnPause.setText("Pause"); - pauseRequested.set(false); - statusLabel.setText("Status: Idle"); - - if (mainPane != null) { - // Adjust index if your tab order changes - mainPane.setSelectedIndex(6); - } - - if (stopAfterCurrent.get()) { - JOptionPane.showMessageDialog(EndpointsTab.this, - "Ping stopped after current request. See Attack Results for partial results.", - "Stopped", JOptionPane.WARNING_MESSAGE); - } else { - JOptionPane.showMessageDialog(EndpointsTab.this, - "Ping complete. See Attack Results for details.", - "Done", JOptionPane.INFORMATION_MESSAGE); + try { + get(); + + resetWorkerControls(); + if (stopAfterCurrent.get()) { + statusLabel.setText("Status: Stopped"); + if (!shutdownRequested.get()) { + showAttackResults(); + JOptionPane.showMessageDialog(EndpointsTab.this, + "Ping stopped after current request. See Attack Results for partial results.", + "Stopped", JOptionPane.WARNING_MESSAGE); + } + } else { + statusLabel.setText("Status: Complete"); + if (!shutdownRequested.get()) { + showAttackResults(); + JOptionPane.showMessageDialog(EndpointsTab.this, + "Ping complete. See Attack Results for details.", + "Done", JOptionPane.INFORMATION_MESSAGE); + } + } + } catch (CancellationException ex) { + resetWorkerControls(); + statusLabel.setText(shutdownRequested.get() + ? "Status: Cancelled on unload" + : "Status: Cancelled"); + if (!shutdownRequested.get()) { + JOptionPane.showMessageDialog(EndpointsTab.this, + "Ping was cancelled.", + "Cancelled", JOptionPane.WARNING_MESSAGE); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + resetWorkerControls(); + statusLabel.setText("Status: Interrupted"); + context.api.logging().logToError("Interrupted while completing endpoint ping.", ex); + if (!shutdownRequested.get()) { + JOptionPane.showMessageDialog(EndpointsTab.this, + "Ping completion was interrupted. See the extension error log for details.", + "Interrupted", JOptionPane.ERROR_MESSAGE); + } + } catch (ExecutionException ex) { + resetWorkerControls(); + statusLabel.setText("Status: Failed"); + Throwable cause = ex.getCause() == null ? ex : ex.getCause(); + if (!shutdownRequested.get()) { + String detail = cause.getMessage(); + if (detail == null || detail.isBlank()) { + detail = cause.getClass().getSimpleName(); + } + JOptionPane.showMessageDialog(EndpointsTab.this, + "Ping failed unexpectedly: " + detail + + "\nSee the extension error log for details.", + "Ping failed", JOptionPane.ERROR_MESSAGE); + } } } }; @@ -525,6 +568,21 @@ protected void done() { activeWorker.execute(); } + private void resetWorkerControls() { + btnPing.setEnabled(true); + btnCancel.setEnabled(false); + btnPause.setEnabled(false); + btnPause.setText("Pause"); + pauseRequested.set(false); + } + + private void showAttackResults() { + if (mainPane != null) { + // Adjust index if your tab order changes + mainPane.setSelectedIndex(6); + } + } + private void updateCountAndEtaOnEDT(int done, int total, long startNano) { runOnEdt(() -> { countLabel.setText(done + " / " + total); @@ -567,6 +625,7 @@ private void waitIfPaused() { * Thread-safe (no UI mutation): wakes a paused worker and interrupts a running one. */ public void shutdown() { + shutdownRequested.set(true); stopAfterCurrent.set(true); pauseRequested.set(false); synchronized (pauseLock) { diff --git a/src/main/java/com/specops/ui/MainTab.java b/src/main/java/com/specops/ui/MainTab.java index 5823dea..11a473f 100644 --- a/src/main/java/com/specops/ui/MainTab.java +++ b/src/main/java/com/specops/ui/MainTab.java @@ -38,7 +38,7 @@ public MainTab(SpecOpsContext context) { tabbedPane.addTab("5. Custom Headers", customHeadersTab); tabbedPane.addTab("6. Endpoints Workbench", endpointsTab); tabbedPane.addTab("7. Attack Results", resultsTab); - tabbedPane.addTab("About", new AboutMeTab("SpecOps", "1.4.0")); + tabbedPane.addTab("About", new AboutMeTab("SpecOps", "1.4.1")); add(tabbedPane, BorderLayout.CENTER); diff --git a/src/main/java/com/specops/ui/ResultsTab.java b/src/main/java/com/specops/ui/ResultsTab.java index 7dd1064..792e05a 100644 --- a/src/main/java/com/specops/ui/ResultsTab.java +++ b/src/main/java/com/specops/ui/ResultsTab.java @@ -263,7 +263,7 @@ private void viewSelectedRequestResponse() { respEditor.setResponse(result.getResponse()); SwingUtilities.invokeLater(() -> { - JDialog dialog = new JDialog((Frame) null, "Request / Response", false); + JDialog dialog = new JDialog(ui.swingUtils().suiteFrame(), "Request / Response", false); dialog.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); JTabbedPane tabs = new JTabbedPane(); tabs.addTab("Request", reqEditor.uiComponent()); diff --git a/src/main/java/com/specops/ui/SpecificationTab.java b/src/main/java/com/specops/ui/SpecificationTab.java index bf73251..edf23a0 100644 --- a/src/main/java/com/specops/ui/SpecificationTab.java +++ b/src/main/java/com/specops/ui/SpecificationTab.java @@ -1,19 +1,28 @@ package com.specops.ui; +import burp.api.montoya.core.ByteArray; import burp.api.montoya.http.Http; +import burp.api.montoya.http.RequestOptions; import burp.api.montoya.http.message.requests.HttpRequest; import burp.api.montoya.http.message.responses.HttpResponse; +import burp.api.montoya.ui.editor.EditorOptions; +import burp.api.montoya.ui.editor.RawEditor; import com.specops.SpecOpsContext; import com.specops.services.openapi.OpenApiParser; import javax.swing.*; import javax.swing.filechooser.FileNameExtensionFilter; +import javax.swing.table.DefaultTableModel; import java.awt.*; import java.io.BufferedReader; import java.io.File; -import java.nio.file.Files; -import java.nio.charset.StandardCharsets; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; /** * Represents the first tab in the SpecOps UI. @@ -24,9 +33,19 @@ public class SpecificationTab extends JPanel { private final SpecOpsContext context; private final JTabbedPane mainPane; - private final JTextArea specArea; + private final RawEditor specArea; + private SpecificationOrigin specificationOrigin = SpecificationOrigin.MANUAL; + private String stagedRemoteBaseHost; private Runnable successCallback; + private enum SpecificationOrigin { + MANUAL, + LOCAL_FILE, + REMOTE_URL + } + + private record LoadedRemoteSpecification(String contents, String baseHost) {} + public SpecificationTab(SpecOpsContext context, JTabbedPane mainPane) { this.context = context; this.mainPane = mainPane; @@ -54,14 +73,12 @@ public SpecificationTab(SpecOpsContext context, JTabbedPane mainPane) { controlPanel.add(fileOperationsPanel, BorderLayout.WEST); controlPanel.add(parsePanel, BorderLayout.EAST); - specArea = new JTextArea("Paste your OpenAPI/Swagger JSON or YAML here, or use the buttons above to load a specification."); - specArea.setFont(new Font("Monospaced", Font.PLAIN, 13)); - specArea.setWrapStyleWord(true); - specArea.setLineWrap(true); - JScrollPane scrollPane = new JScrollPane(specArea); + specArea = context.api.userInterface().createRawEditor(EditorOptions.WRAP_LINES); + specArea.setEditable(true); + setSpecificationText("Paste your OpenAPI/Swagger JSON or YAML here, or use the buttons above to load a specification."); add(controlPanel, BorderLayout.NORTH); - add(scrollPane, BorderLayout.CENTER); + add(specArea.uiComponent(), BorderLayout.CENTER); } /** @@ -95,8 +112,9 @@ protected String doInBackground() throws Exception { @Override protected void done() { try { - specArea.setText(get()); - specArea.setCaretPosition(0); // Scroll to top + setSpecificationText(get()); + specificationOrigin = SpecificationOrigin.LOCAL_FILE; + stagedRemoteBaseHost = null; } catch (Exception e) { context.api.logging().logToError("Failed to read file: " + e.getMessage()); JOptionPane.showMessageDialog(SpecificationTab.this, "Error reading file: " + e.getMessage(), "File Error", JOptionPane.ERROR_MESSAGE); @@ -114,23 +132,26 @@ private void loadFromUrl() { return; } - specArea.setText("Loading from " + urlString + "..."); + setSpecificationText("Loading from " + urlString + "..."); + specificationOrigin = SpecificationOrigin.MANUAL; + stagedRemoteBaseHost = null; - new SwingWorker() { + new SwingWorker() { @Override - protected String doInBackground() { + protected LoadedRemoteSpecification doInBackground() { try { - // Record base host for later URL url = new URL(urlString.trim()); String host = url.getProtocol() + "://" + url.getHost() + (url.getPort() == -1 ? "" : ":" + url.getPort()); - context.setApiHost(host); Http http = context.api.http(); HttpRequest request = HttpRequest.httpRequestFromUrl(urlString.trim()) .withHeader("User-Agent", "SpecOps/1.0"); - HttpResponse resp = http.sendRequest(request).response(); + HttpResponse resp = http.sendRequest( + request, + RequestOptions.requestOptions().withUpstreamTLSVerification() + ).response(); int status = resp.statusCode(); if (status < 200 || status >= 300) { @@ -138,7 +159,10 @@ protected String doInBackground() { } byte[] bodyBytes = resp.body().getBytes(); - return new String(bodyBytes, StandardCharsets.UTF_8); + return new LoadedRemoteSpecification( + new String(bodyBytes, StandardCharsets.UTF_8), + host + ); } catch (Exception ex) { context.api.logging().logToError("Failed to load from URL via Montoya: " + ex); throw new RuntimeException(ex); @@ -148,13 +172,16 @@ protected String doInBackground() { @Override protected void done() { try { - specArea.setText(get()); - specArea.setCaretPosition(0); + LoadedRemoteSpecification loaded = get(); + setSpecificationText(loaded.contents()); + specificationOrigin = SpecificationOrigin.REMOTE_URL; + stagedRemoteBaseHost = loaded.baseHost(); } catch (Exception e) { - specArea.setText("Failed to load from URL: " + e.getMessage()); + String message = failureMessage(e); + setSpecificationText("Failed to load from URL: " + message); JOptionPane.showMessageDialog( SpecificationTab.this, - "Error loading from URL: " + e.getMessage(), + "Error loading from URL: " + message, "URL Error", JOptionPane.ERROR_MESSAGE ); @@ -164,15 +191,18 @@ protected void done() { } private void parseSpecification() { - String specContent = specArea.getText(); + String specContent = specArea.getContents().toString(); if (specContent.trim().isEmpty()) { JOptionPane.showMessageDialog(this, "Specification content is empty.", "Error", JOptionPane.ERROR_MESSAGE); return; } - new SwingWorker() { + SpecificationOrigin originAtParse = specificationOrigin; + String remoteBaseHostAtParse = stagedRemoteBaseHost; + + new SwingWorker, Void>() { @Override - protected Boolean doInBackground() { + protected Optional doInBackground() { OpenApiParser parser = new OpenApiParser(context); return parser.parse(specContent); } @@ -180,16 +210,29 @@ protected Boolean doInBackground() { @Override protected void done() { try { - if (get()) { - JOptionPane.showMessageDialog(SpecificationTab.this, "Specification parsed successfully!\n" - + context.getEndpoints().size() + " endpoints found.", "Success", JOptionPane.INFORMATION_MESSAGE); - if (successCallback != null) { - successCallback.run(); - } - mainPane.setSelectedIndex(1); - } else { + Optional parsed = get(); + if (parsed.isEmpty()) { JOptionPane.showMessageDialog(SpecificationTab.this, "Failed to parse the specification. See the extension output for details.", "Parsing Error", JOptionPane.ERROR_MESSAGE); + return; } + + OpenApiParser.ParsedSpecification staged = parsed.get(); + if (originAtParse == SpecificationOrigin.REMOTE_URL + && !confirmRemoteAutomaticValues(staged.automaticValues())) { + return; + } + + if (originAtParse == SpecificationOrigin.REMOTE_URL) { + context.setApiHost(remoteBaseHostAtParse); + } + context.resetModel(staged.openAPI(), staged.endpoints(), staged.parameters()); + + JOptionPane.showMessageDialog(SpecificationTab.this, "Specification parsed successfully!\n" + + staged.endpoints().size() + " endpoints found.", "Success", JOptionPane.INFORMATION_MESSAGE); + if (successCallback != null) { + successCallback.run(); + } + mainPane.setSelectedIndex(1); } catch (Exception e) { context.api.logging().logToError("An unexpected error occurred during parsing: " + e.getMessage()); JOptionPane.showMessageDialog(SpecificationTab.this, "An unexpected error occurred: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); @@ -197,4 +240,116 @@ protected void done() { } }.execute(); } + + private void setSpecificationText(String text) { + byte[] bytes = (text == null ? "" : text).getBytes(StandardCharsets.UTF_8); + specArea.setContents(ByteArray.byteArray(bytes)); + specArea.setCaretPosition(0); + } + + private boolean confirmRemoteAutomaticValues(List values) { + if (values.isEmpty()) { + return true; + } + + List sorted = values.stream() + .sorted(Comparator + .comparing(OpenApiParser.AutomaticValue::endpoint, String.CASE_INSENSITIVE_ORDER) + .thenComparing(OpenApiParser.AutomaticValue::location, String.CASE_INSENSITIVE_ORDER) + .thenComparing(OpenApiParser.AutomaticValue::parameter, String.CASE_INSENSITIVE_ORDER) + .thenComparing(OpenApiParser.AutomaticValue::source, String.CASE_INSENSITIVE_ORDER)) + .toList(); + + String[] columns = {"Endpoint", "Location", "Parameter / path", "Source", "Value preview"}; + Object[][] rows = sorted.stream() + .map(value -> new Object[]{ + escapeForDisplay(value.endpoint()), + escapeForDisplay(value.location()), + escapeForDisplay(value.parameter()), + escapeForDisplay(value.source()), + escapeForDisplay(value.value()) + }) + .toArray(Object[][]::new); + + DefaultTableModel model = new DefaultTableModel(rows, columns) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + JTable table = new JTable(model); + table.setAutoCreateRowSorter(true); + table.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN); + table.getColumnModel().getColumn(0).setPreferredWidth(160); + table.getColumnModel().getColumn(1).setPreferredWidth(70); + table.getColumnModel().getColumn(2).setPreferredWidth(190); + table.getColumnModel().getColumn(3).setPreferredWidth(100); + table.getColumnModel().getColumn(4).setPreferredWidth(300); + + JScrollPane tableScrollPane = new JScrollPane(table); + tableScrollPane.setPreferredSize(new Dimension(900, Math.min(340, 70 + rows.length * 22))); + + // Burp's look-and-feel disables HTML rendering in Swing labels, so keep the + // header as plain-text lines rather than an block. + Box header = Box.createVerticalBox(); + header.add(new JLabel("This remote specification supplies non-empty defaults, examples, or enum values " + + "that can be used in generated requests.")); + header.add(new JLabel("Review them before applying the specification.")); + + JPanel message = new JPanel(new BorderLayout(0, 10)); + message.add(header, BorderLayout.NORTH); + message.add(tableScrollPane, BorderLayout.CENTER); + + String apply = "Apply values"; + String cancel = "Cancel parse"; + int choice = JOptionPane.showOptionDialog( + this, + message, + "Confirm values from remote specification", + JOptionPane.DEFAULT_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + new Object[]{apply, cancel}, + cancel + ); + return choice == 0; + } + + private static String escapeForDisplay(String value) { + if (value == null) return ""; + + StringBuilder escaped = new StringBuilder(); + value.codePoints().forEach(codePoint -> { + switch (codePoint) { + case '\r' -> escaped.append("\\r"); + case '\n' -> escaped.append("\\n"); + case '\t' -> escaped.append("\\t"); + case '<' -> escaped.append("\\u003C"); + case '>' -> escaped.append("\\u003E"); + default -> { + if (Character.isISOControl(codePoint)) { + escaped.append(String.format("\\u%04X", codePoint)); + } else { + escaped.appendCodePoint(codePoint); + } + } + } + }); + + int maxPreviewLength = 240; + return escaped.length() > maxPreviewLength + ? escaped.substring(0, maxPreviewLength) + "…" + : escaped.toString(); + } + + private static String failureMessage(Exception exception) { + Throwable cause = exception; + if (exception instanceof ExecutionException && exception.getCause() != null) { + cause = exception.getCause(); + } + while (cause.getMessage() == null && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause.getMessage() != null ? cause.getMessage() : cause.toString(); + } }