diff --git a/.github/scripts/check_flyway_migration_order.sh b/.github/scripts/check_flyway_migration_order.sh index b8a7a019..349ddd24 100755 --- a/.github/scripts/check_flyway_migration_order.sh +++ b/.github/scripts/check_flyway_migration_order.sh @@ -58,7 +58,7 @@ MODIFIED_FILES=$(git diff --name-only --diff-filter=M "$BASE_REF" HEAD -- "$MIGR BASE_MAX_FILE="" BASE_MAX_KEY="0" while IFS= read -r file; do - [ -z "$file" ] && continue + [[ -z "$file" ]] && continue if [[ ! $(basename "$file") =~ $NAME_PATTERN ]]; then continue fi @@ -69,13 +69,13 @@ while IFS= read -r file; do fi done <<< "$BASE_FILES" -if [ -n "$BASE_MAX_FILE" ]; then +if [[ -n "$BASE_MAX_FILE" ]]; then printf -- "Highest migration on %s: %s (version %s)\n" "$BASE_REF" "$(basename "$BASE_MAX_FILE")" "$(version_label "$BASE_MAX_FILE")" else printf -- "No migration found on %s\n" "$BASE_REF" fi -if [ -z "$ADDED_FILES" ] && [ -z "$DELETED_FILES" ] && [ -z "$MODIFIED_FILES" ]; then +if [[ -z "$ADDED_FILES" ]] && [[ -z "$DELETED_FILES" ]] && [[ -z "$MODIFIED_FILES" ]]; then printf -- "No migration change detected in this pull request.\n" : > "$REPORT_FILE" exit 0 @@ -83,13 +83,13 @@ fi # Rule 1: an already merged migration must never be modified or deleted while IFS= read -r file; do - [ -z "$file" ] && continue + [[ -z "$file" ]] && continue ERRORS+=("\`$file\` is modified. A migration already merged on \`$BASE_REF\` has been applied on running environments: its checksum is frozen. Create a new migration instead.") EXIT_CODE=1 done <<< "$MODIFIED_FILES" while IFS= read -r file; do - [ -z "$file" ] && continue + [[ -z "$file" ]] && continue ERRORS+=("\`$file\` is deleted or renamed. A migration already merged on \`$BASE_REF\` must be kept as is. Create a new migration instead.") EXIT_CODE=1 done <<< "$DELETED_FILES" @@ -97,7 +97,7 @@ done <<< "$DELETED_FILES" # Rule 2: added migrations must be well named and strictly above the highest merged version SEEN_KEYS=() while IFS= read -r file; do - [ -z "$file" ] && continue + [[ -z "$file" ]] && continue name=$(basename "$file") if [[ ! $name =~ $NAME_PATTERN ]]; then @@ -109,14 +109,14 @@ while IFS= read -r file; do key=$(version_key "$file") label=$(version_label "$file") - if [ -n "$BASE_MAX_FILE" ] && awk -v a="$key" -v b="$BASE_MAX_KEY" 'BEGIN { exit !(a <= b) }'; then + if [[ -n "$BASE_MAX_FILE" ]] && awk -v a="$key" -v b="$BASE_MAX_KEY" 'BEGIN { exit !(a <= b) }'; then ERRORS+=("\`$name\` (version \`$label\`) is lower than or equal to \`$(basename "$BASE_MAX_FILE")\` (version \`$(version_label "$BASE_MAX_FILE")\`) already merged on \`$BASE_REF\`. Renumber it above \`$(version_label "$BASE_MAX_FILE")\` so Flyway applies it on every environment.") EXIT_CODE=1 continue fi for seen in ${SEEN_KEYS[@]+"${SEEN_KEYS[@]}"}; do - if [ "${seen%%:*}" = "$key" ]; then + if [[ "${seen%%:*}" = "$key" ]]; then ERRORS+=("\`$name\` uses version \`$label\` already used by \`${seen#*:}\` in this pull request. Flyway versions must be unique.") EXIT_CODE=1 fi @@ -128,11 +128,11 @@ done <<< "$ADDED_FILES" # Build the markdown report { - if [ "$EXIT_CODE" -eq 0 ]; then + if [[ "$EXIT_CODE" -eq 0 ]]; then printf -- "No Flyway migration ordering issue detected.\n\n" printf -- "| Added migration | Version |\n|---|---|\n" while IFS= read -r file; do - [ -z "$file" ] && continue + [[ -z "$file" ]] && continue printf -- "| \`%s\` | \`%s\` |\n" "$(basename "$file")" "$(version_label "$file")" done <<< "$ADDED_FILES" else @@ -141,7 +141,7 @@ done <<< "$ADDED_FILES" printf -- "- %s\n" "$error" done printf -- "\nHighest migration currently on \`%s\`: " "$BASE_REF" - if [ -n "$BASE_MAX_FILE" ]; then + if [[ -n "$BASE_MAX_FILE" ]]; then printf -- "\`%s\`\n" "$(basename "$BASE_MAX_FILE")" else printf -- "none\n" @@ -151,7 +151,7 @@ done <<< "$ADDED_FILES" cat "$REPORT_FILE" -if [ "$EXIT_CODE" -ne 0 ]; then +if [[ "$EXIT_CODE" -ne 0 ]]; then for error in "${ERRORS[@]}"; do printf -- "::error:: %s\n" "$(printf -- '%s' "$error" | tr -d '`')" done diff --git a/.github/scripts/check_navigation.sh b/.github/scripts/check_navigation.sh index 0af04906..47c2d3e5 100755 --- a/.github/scripts/check_navigation.sh +++ b/.github/scripts/check_navigation.sh @@ -12,7 +12,7 @@ echo "🔍 Checking for broken navigation links in ${SITE_DIR}..." # Find files with 'none' placeholders in navigation while IFS= read -r file; do - if [ -n "$file" ]; then + if [[ -n "$file" ]]; then # Get line numbers where 'none' appears in md-ellipsis context line_nums=$(grep -n "^\s*none\s*$" "$file" | cut -d: -f1 2>/dev/null || true) @@ -39,7 +39,7 @@ while IFS= read -r file; do fi done < <(find "$SITE_DIR" -name "*.html" -type f) -if [ $EXIT_CODE -eq 0 ]; then +if [[ $EXIT_CODE -eq 0 ]]; then echo "✅ No broken navigation links found" else echo "" @@ -54,7 +54,7 @@ else # Try to find it in the source directory found_files=$(find "$SRC_DIR" -name "$filename" 2>/dev/null || true) - if [ -n "$found_files" ]; then + if [[ -n "$found_files" ]]; then echo "$found_files" | while read -r found; do echo " ❌ $found" done diff --git a/.github/scripts/extract_specs.sh b/.github/scripts/extract_specs.sh index 5ad8dea4..62cf84fd 100755 --- a/.github/scripts/extract_specs.sh +++ b/.github/scripts/extract_specs.sh @@ -13,7 +13,7 @@ PR_BRANCH=$(git rev-parse --abbrev-ref HEAD) git fetch origin main git checkout origin/main # 1. Check if the static swagger file exists on the 'main' branch -if [ -f "$MAIN_SWAGGER" ]; then +if [[ -f "$MAIN_SWAGGER" ]]; then # If it exists, copy it as the reference spec cp "$MAIN_SWAGGER" "specs/static-swagger-main.yaml" else @@ -22,7 +22,7 @@ else mvn clean package -B JAR_PATH=$(find target -maxdepth 1 -name "*.jar" | head -n 1) - if [ -z "$JAR_PATH" ]; then + if [[ -z "$JAR_PATH" ]]; then printf -- "::error:: No JAR found in target/ folder.\n" exit 1 fi @@ -39,7 +39,7 @@ else printf -- "Waiting for API endpoint (%s) to be reachable...\n" "$API_URL" - while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do + while [[ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]]; do # Use curl with --fail and --silent to check for a successful connection (HTTP 2xx response) if curl --fail --silent -o /dev/null "$API_URL"; then printf -- "API is up after %d seconds.\n" "$((ATTEMPT * SLEEP_TIME))" @@ -50,7 +50,7 @@ else ATTEMPT=$((ATTEMPT + 1)) done # Check if the maximum number of attempts was reached (timeout) - if [ "$ATTEMPT" -eq "$MAX_ATTEMPTS" ]; then + if [[ "$ATTEMPT" -eq "$MAX_ATTEMPTS" ]]; then printf -- "::error:: Application failed to start and expose Swagger endpoint after %d attempts.\n" "$MAX_ATTEMPTS" printf -- "Swagger endpoint unreachable. Dumping logs:\n" pgrep -a java || printf -- "No Java process found\n" diff --git a/.github/scripts/summarize_owasp.sh b/.github/scripts/summarize_owasp.sh index 561bc043..e2d740d6 100755 --- a/.github/scripts/summarize_owasp.sh +++ b/.github/scripts/summarize_owasp.sh @@ -6,19 +6,19 @@ SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/stdout}" echo "### OWASP Dependency Check Summary" >> "$SUMMARY_FILE" SARIF_REPORT="" -if [ -f "$PWD/target/security-reports/dependency-check-report.sarif" ]; then +if [[ -f "$PWD/target/security-reports/dependency-check-report.sarif" ]]; then SARIF_REPORT="$PWD/target/security-reports/dependency-check-report.sarif" -elif [ -f "$PWD/target/dependency-check-report.sarif" ]; then +elif [[ -f "$PWD/target/dependency-check-report.sarif" ]]; then SARIF_REPORT="$PWD/target/dependency-check-report.sarif" fi -if [ -n "$SARIF_REPORT" ]; then +if [[ -n "$SARIF_REPORT" ]]; then echo "| Vulnerability | CVSS | Package |" >> "$SUMMARY_FILE" echo "|:--|:--:|:--|" >> "$SUMMARY_FILE" if command -v jq >/dev/null 2>&1; then VULN_COUNT=$(jq '.runs[].results | length' "$SARIF_REPORT" | awk '{s+=$1} END {print s}') - if [ "$VULN_COUNT" -gt 0 ]; then + if [[ "$VULN_COUNT" -gt 0 ]]; then jq -r '.runs[].results[] | [.ruleId, (.properties.cvssScore // "N/A"), (.message.text | split("\n")[0])] | @tsv' "$SARIF_REPORT" | while IFS=$'\t' read -r ruleId cvss message; do printf "| %s | %s | %s |\n" "$ruleId" "$cvss" "$message" >> "$SUMMARY_FILE" diff --git a/.gitignore b/.gitignore index fee3e653..46a6e700 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ buildNumber.properties .env application-secret.* - +flyway-migration-order-report.md # BlueJ files *.ctxt diff --git a/Dockerfile b/Dockerfile index 7dd4bd05..6793753f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,15 @@ FROM eclipse-temurin:25-jre-alpine ENV PORT=8080 -RUN apk add --no-cache shadow && \ - groupadd -r idp-core-group && \ - useradd --no-log-init -r -g idp-core-group idp-core-user && \ - apk del shadow +RUN addgroup -S idp-core-group && \ + adduser -S -D -H -G idp-core-group idp-core-user USER idp-core-user:idp-core-group WORKDIR /opt COPY ./target/idp-core*.jar /opt/app.jar +HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:$PORT/actuator/health || exit 1 + ENTRYPOINT ["java", "-jar", "/opt/app.jar"] diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 1eb4f429..7c8ae09f 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -274,6 +274,8 @@ Retrieve a paginated list of entities for a given template: GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc ``` +`size` must be between 1 and the configured maximum (default 500, set with `MAX_PAGE_SIZE`), values outside this range return `400 Bad Request`. + ### Get Entity by Identifier Retrieve a specific entity using its template and entity identifiers: diff --git a/docs/src/concepts/entity-filtering.md b/docs/src/concepts/entity-filtering.md index c0987c46..e8cfb246 100644 --- a/docs/src/concepts/entity-filtering.md +++ b/docs/src/concepts/entity-filtering.md @@ -183,7 +183,7 @@ Request shape (`EntitySearchRequestDtoIn`): - `filter` is a tree of group nodes and criterion nodes. A group node has `connector` (one of `AND`,`OR`) and a non-empty `criteria` array. A criterion node must include `field`, `operation` and `value`. - Supported `operation` values: `EQ`, `NEQ`, `CONTAINS`, `NOT_CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `GT`, `GTE`, `LT`, `LTE`. - Free-text `query` (optional) performs a case-insensitive contains search across identifier, name, template identifier and all property values. -- Pagination: `page` is zero-based, `size` defaults to 20. Maximum allowed `size` is 500. +- Pagination: `page` is zero-based, `size` defaults to 20. Maximum allowed `size` is 500 by default (configurable with `MAX_PAGE_SIZE`). ### What it does @@ -202,7 +202,7 @@ curl -sS -X POST "http://localhost:8084/api/v1/entities/search" \ - `query` (optional): free-text string (max 255 chars) searched case-insensitively in `identifier`, `name`, `templateIdentifier`, and property values. - `filter` (optional): nested `FilterNodeDtoIn` JSON structure. Use a `template` criterion to scope results to a specific template when needed. -- `page` / `size`: pagination (zero-based `page`). `size` defaults to 20 and the server enforces a maximum of 500. +- `page` / `size`: pagination (zero-based `page`). `size` defaults to 20 and the server enforces a maximum of 500 by default (configurable with `MAX_PAGE_SIZE`). - `sort`: `field:asc|desc`. Allowed sort fields: `identifier`, `name`, `templateIdentifier`. ### Operator semantics @@ -221,7 +221,7 @@ curl -sS -X POST "http://localhost:8084/api/v1/entities/search" \ - Maximum filter nesting depth: 5 levels. Requests exceeding this fail with `400 Bad Request` and a descriptive error message. - Maximum total criterion count across the tree: 50. Requests exceeding this fail with `400 Bad Request`. - Maximum `query` length: 255 characters. -- Maximum `size` (page size): 500. +- Maximum `size` (page size): 500 by default, configurable with `MAX_PAGE_SIZE`. ## Next Steps diff --git a/docs/src/features/entity-dynamic-mapping.md b/docs/src/features/entity-dynamic-mapping.md index 3d16dcf2..e7cc22e6 100644 --- a/docs/src/features/entity-dynamic-mapping.md +++ b/docs/src/features/entity-dynamic-mapping.md @@ -195,6 +195,17 @@ if (.is_private == true) "PRIVATE_" + .name else .name } ``` +### JSLT Execution Limits + +To protect the platform against denial-of-service, JSLT processing is bounded: + +| Limit | Value | Exceeded behavior | +| ------------------ | ------------------------ | --------------------------------------- | +| Payload size | 1 MB (UTF-8 bytes) | Rejected before parsing (400) | +| JSON nesting depth | 64 levels | Rejected before the tree is built (400) | +| Expression length | 10,000 characters | Rejected at validation (422) | +| Evaluation timeout | 5 seconds per evaluation | Evaluation aborted (422) | + > [!TIP] > Use the **dry-run endpoint** (POST `/api/v1/entity_dynamic_mappings/dry-run`) to test > your JSLT expressions before deploying them to production. This helps catch syntax diff --git a/docs/src/features/webhooks.md b/docs/src/features/webhooks.md index 0bb8b2f4..166e5378 100644 --- a/docs/src/features/webhooks.md +++ b/docs/src/features/webhooks.md @@ -86,7 +86,7 @@ again at runtime. | `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | | `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | | `JWT_BEARER` | `jwks_uri`, `client_id_field`, `client_id_values` | Validates the bearer token against a JWKS endpoint, then checks caller identity claim allow-list | -| `NONE` | none (`config` is optional) | Skips authentication | +| `NONE` | none (`config` is optional) | Skips authentication. Disabled unless `IDP_WEBHOOK_SECURITY_ALLOW_NONE=true` | > [!IMPORTANT] > Configuration keys accept `snake_case` and `camelCase` variants where supported. HMAC prefix uses the `prefix` key and has a default set to `sha256=`. @@ -157,6 +157,8 @@ value in the connector configuration. For `NONE`, you can omit `config`, set it to `null`, or pass `{}`. Any non-empty `config` is rejected. + `NONE` is disabled by default. Set the `IDP_WEBHOOK_SECURITY_ALLOW_NONE` environment variable to `true` to allow creating or updating connectors with `NONE`. When the flag is off, a request with `NONE` or with the `security` section omitted is rejected with `400 Bad Request`. Connectors that already use `NONE` continue to ingest events. + ### JWT_BEARER Configuration Reference - `jwks_uri` (required): literal HTTPS URL used to validate JWT signatures. diff --git a/docs/src/getting-started/configuration.md b/docs/src/getting-started/configuration.md index a5b573e4..c251d255 100644 --- a/docs/src/getting-started/configuration.md +++ b/docs/src/getting-started/configuration.md @@ -75,17 +75,12 @@ Configure CORS if your frontend loads from a different origin or if no API gatew spring: web: cors: - allowed-origins: - - http://localhost:3000 - - https://your-frontend.com - allowed-methods: - - GET - - POST - - PUT - - DELETE - allowed-headers: "*" + allowed-origins: http://localhost:3000,https://your-frontend.com + allowed-origin-patterns: https://*.your-domain.com ``` +Set exact origins with `spring.web.cors.allowed-origins` or the `SPRING_WEB_CORS_ALLOWED_ORIGINS` environment variable. Set wildcard origin patterns with `spring.web.cors.allowed-origin-patterns` or the `SPRING_WEB_CORS_ALLOWED_ORIGIN_PATTERNS` environment variable. Separate multiple values with commas. + --- ## Security Configuration @@ -153,6 +148,7 @@ All configuration can be overridden using environment variables: | `SPRING_DATASOURCE_PASSWORD` | DB password | `secret` | | `SERVER_PORT` | HTTP port | `8084` | | `LOGGING_LEVEL_ROOT` | Log level | `INFO` | +| `MAX_PAGE_SIZE` | Maximum `size` accepted by search and listing endpoints (default `500`) | `1000` | ### Naming Convention diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 05aaa68b..888dc312 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -535,7 +535,7 @@ paths: default: '0' - name: size in: query - description: Number of items per page. Defaults to 20. + description: Number of items per page. Defaults to 20. Must be between 1 and 500. content: "*/*": schema: @@ -614,7 +614,7 @@ paths: default: '0' - name: size in: query - description: Number of items per page. Defaults to 20. + description: Number of items per page. Defaults to 20. Must be between 1 and 500. content: "*/*": schema: @@ -732,7 +732,7 @@ paths: default: '0' - name: size in: query - description: Number of items per page. Defaults to 20. + description: Number of items per page. Defaults to 20. Must be between 1 and 500. content: "*/*": schema: @@ -804,7 +804,7 @@ paths: default: '0' - name: size in: query - description: Number of items per page. Defaults to 20. + description: Number of items per page. Defaults to 20. Must be between 1 and 500. required: false content: "*/*": @@ -1859,7 +1859,7 @@ components: type: integer format: int32 default: 20 - description: Number of items per page. Defaults to 20. + description: Number of items per page. Defaults to 20. Must be between 1 and 500. example: 20 sort: type: string diff --git a/flyway-migration-order-report.md b/flyway-migration-order-report.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/JsltConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/JsltConstraints.java new file mode 100644 index 00000000..2a0e4d67 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/constant/JsltConstraints.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.domain.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Domain constants for JSLT mapping safety limits (DoS prevention). +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class JsltConstraints { + + /// Maximum size (in UTF-8 bytes) of a raw payload accepted for mapping. + public static final int MAX_PAYLOAD_BYTES = 1024 * 1024; + + /// Maximum JSON nesting depth of a raw payload accepted for mapping. + public static final int MAX_JSON_DEPTH = 64; + + /// Maximum length (in characters) of a JSLT expression. + public static final int MAX_EXPRESSION_LENGTH = 10_000; + + /// Maximum wall-clock time (in milliseconds) allowed for one JSLT evaluation. + public static final long EVALUATION_TIMEOUT_MS = 5_000L; +} diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java index 08723413..e2964a76 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java @@ -11,8 +11,14 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class SearchConstraints { - /// Maximum number of entities returned per page in a search request. - public static final int MAX_PAGE_SIZE = 500; + /// Page size applied when a request omits `size`. Capped at the configured + /// maximum. + public static final int DEFAULT_PAGE_SIZE = 20; + + /// Default maximum number of entities returned per page in a search request. + /// The effective limit is configurable via `app.max-page-size` (env + /// `MAX_PAGE_SIZE`). + public static final int DEFAULT_MAX_PAGE_SIZE = 500; /// Maximum length (in characters) of the free-text `query` parameter. public static final int MAX_QUERY_LENGTH = 255; diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index b230f849..bb24a561 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -100,6 +100,11 @@ public static String minMaxConstraintViolated(String constraint) { return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); } + // Pagination validation messages + public static final String PAGE_SIZE_TOO_LARGE = "Page size must not exceed %d"; + public static final String PAGE_SIZE_NOT_INTEGER = "Page size must be an integer"; + public static final String PAGE_INDEX_NOT_INTEGER = "Page index must be an integer"; + // Filter query validation messages public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; @@ -124,7 +129,6 @@ public static String minMaxConstraintViolated(String constraint) { public static final String SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY = "Operator '%s' is only valid for property.{name} fields"; public static final String SEARCH_INVALID_SORT_FIELD = "Invalid sort field '%s'. Supported fields: identifier, name, templateIdentifier"; public static final String SEARCH_INVALID_SORT_FORMAT = "Invalid sort expression '%s'. Expected format: field or field:asc|desc"; - public static final String SEARCH_PAGE_SIZE_TOO_LARGE = "Page size must not exceed %d"; public static final String SEARCH_PAGE_INVALID = "Page index must be 0 or greater"; public static final String SEARCH_SIZE_INVALID = "Page size must be greater than 0"; public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'"; diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurityValidationPolicy.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurityValidationPolicy.java new file mode 100644 index 00000000..b19b5a8f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurityValidationPolicy.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +/// Domain policy controlling which webhook security configurations are allowed. +/// +/// @param allowNone whether connectors may be created without webhook authentication +public record WebhookSecurityValidationPolicy(boolean allowNone) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java b/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java index 9c425a5a..dd283c61 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java @@ -1,4 +1,32 @@ package com.decathlon.idp_core.domain.model.search; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; + public record PaginationCriteria(int page, int size, String sort) { + + /// Checks that page is non-negative and size is at least 1, without an upper + /// bound. + /// + /// @throws InvalidSearchQueryException when page or size is out of range + public void validateRange() { + if (page < 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); + } + if (size <= 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); + } + } + + /// Checks that page is non-negative and size is between 1 and `maxPageSize`. + /// + /// @param maxPageSize configured maximum number of items per page + /// @throws InvalidSearchQueryException when page or size is out of bounds + public void validateBounds(int maxPageSize) { + validateRange(); + if (size > maxPageSize) { + throw new InvalidSearchQueryException( + ValidationMessages.PAGE_SIZE_TOO_LARGE.formatted(maxPageSize)); + } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java index 592e3253..5b1aaaf7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java @@ -7,7 +7,6 @@ import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; import com.decathlon.idp_core.domain.port.audit.EntityAuditPort; -import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import lombok.RequiredArgsConstructor; @@ -28,7 +27,6 @@ public class EntityAuditService { private final EntityAuditPort entityAuditPort; - private final EntityTemplateService entityTemplateService; /// Retrieves the complete audit history for a specific entity. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 6a583887..6ceac5ce 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -636,9 +636,9 @@ private Entity retrieveEntity(final String templateIdentifier, final String enti /// @throws InvalidSearchQueryException when pagination parameters are invalid @Transactional(readOnly = true) public PaginatedResult searchEntities(SearchFilterNode filter, String query, - PaginationCriteria paginationCriteria) { + PaginationCriteria paginationCriteria, int maxPageSize) { searchFilterValidationService.validate(filter, query); - validatePaginationCriteria(paginationCriteria); + validatePaginationCriteria(paginationCriteria, maxPageSize); return entityRepository.search(filter, query, paginationCriteria); } @@ -654,17 +654,8 @@ public boolean entityExists(String templateIdentifier, String entityIdentifier) .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier).isPresent(); } - private void validatePaginationCriteria(PaginationCriteria criteria) { - if (criteria.page() < 0) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); - } - if (criteria.size() <= 0) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); - } - if (criteria.size() > SearchConstraints.MAX_PAGE_SIZE) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(SearchConstraints.MAX_PAGE_SIZE)); - } + private void validatePaginationCriteria(PaginationCriteria criteria, int maxPageSize) { + criteria.validateBounds(maxPageSize); String sort = criteria.sort(); if (sort != null && !sort.isBlank()) { validateSortExpression(sort); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 1143d495..51e0c733 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -7,12 +7,14 @@ import java.util.UUID; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.model.entity.EntityFilter; @@ -20,6 +22,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphQueryOptions; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity.EntityService; @@ -120,17 +123,23 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId /// produce /// unified `relations` DTOs without any further DB calls. /// - /// @param pageable pagination and sorting configuration + /// @param paginationCriteria zero-based page and page size, validated against + /// `maxPageSize`; the sort is not applied /// @param templateIdentifier template to scope the entity list /// @param entityFilter optional filter criteria; `null` means no filtering /// @param depth the maximum relationship depth used for each page entity's /// graph traversal /// @return paginated graph nodes with outbound and inbound relations resolved + /// @param maxPageSize the largest page size accepted /// @throws EntityTemplateNotFoundException when the template does not exist + /// @throws InvalidSearchQueryException when page or size is out of bounds @SuppressWarnings("null") @Transactional(readOnly = true) - public Page getEntityGraphPageByTemplate(Pageable pageable, - String templateIdentifier, EntityFilter entityFilter, int depth) { + public Page getEntityGraphPageByTemplate(PaginationCriteria paginationCriteria, + String templateIdentifier, EntityFilter entityFilter, int depth, int maxPageSize) { + + paginationCriteria.validateBounds(maxPageSize); + Pageable pageable = PageRequest.of(paginationCriteria.page(), paginationCriteria.size()); // Fetch paginated entities — template existence is validated inside this call Page entityPage = entityService.getEntitiesByTemplateIdentifier(pageable, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java index 2da3d4de..12d3ca7d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java @@ -8,19 +8,24 @@ import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurityValidationPolicy; import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; /// Domain service for validating webhook security configuration at creation/update time. /// /// This service ensures that the security configuration provided when creating or updating -/// a webhook connector is valid before storing it in the database. +/// a webhook connector is valid before storing it in the database. The `NONE` security type +/// (unauthenticated webhooks) is rejected unless `IDP_WEBHOOK_SECURITY_ALLOW_NONE` is enabled. @Service public class WebhookSecurityValidationService { private final List strategies; + private final WebhookSecurityValidationPolicy policy; - public WebhookSecurityValidationService(List strategies) { + public WebhookSecurityValidationService(List strategies, + WebhookSecurityValidationPolicy policy) { this.strategies = List.copyOf(strategies); + this.policy = policy; } /// Validates webhook security configuration for creation or update. @@ -36,6 +41,10 @@ public void validateForCreation(WebhookSecurity security) { Map config = security.config(); if (security.type() == WebhookSecurityType.NONE) { + if (!policy.allowNone()) { + throw new WebhookSecurityConfigurationException( + "Webhook security type NONE is disabled. Set IDP_WEBHOOK_SECURITY_ALLOW_NONE=true to allow it"); + } validateNoSecurityConfig(config); return; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java index 5a8a48f4..428f2637 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java @@ -7,8 +7,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; import org.springframework.http.server.PathContainer; -import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SearchProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SearchProperties.java new file mode 100644 index 00000000..2d0b59a1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SearchProperties.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import jakarta.validation.constraints.Min; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.constant.SearchConstraints; + +/// Configuration properties for search and listing pagination. +/// +/// Binds `app.max-page-size`, which can be overridden with the +/// `MAX_PAGE_SIZE` environment variable. Defaults to +/// [SearchConstraints#DEFAULT_MAX_PAGE_SIZE]. +@Validated +@ConfigurationProperties(prefix = "app") +public record SearchProperties( + @DefaultValue("" + SearchConstraints.DEFAULT_MAX_PAGE_SIZE) @Min(1) int maxPageSize) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index fbb27c69..31fc9527 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -221,7 +221,7 @@ public class SwaggerDescription { // --- Pagination and sorting parameter descriptions --- public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20. Must be between 1 and 500."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; public static final String PARAM_QUERY_DESCRIPTION = """ Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/ValidatingPageableArgumentResolver.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/ValidatingPageableArgumentResolver.java new file mode 100644 index 00000000..e9208424 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/ValidatingPageableArgumentResolver.java @@ -0,0 +1,63 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import java.util.Optional; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; + +/// Resolves Spring Data `Pageable` arguments and rejects invalid `page` and `size` +/// query parameters with [InvalidSearchQueryException] (HTTP 400). +public class ValidatingPageableArgumentResolver extends PageableHandlerMethodArgumentResolver { + + private static final String PAGE_PARAM = "page"; + private static final String SIZE_PARAM = "size"; + + private final int maxPageSize; + + public ValidatingPageableArgumentResolver(int maxPageSize) { + this.maxPageSize = maxPageSize; + setMaxPageSize(maxPageSize); + } + + @Override + public @NonNull Pageable resolveArgument(@NonNull MethodParameter methodParameter, + @Nullable ModelAndViewContainer mavContainer, @NonNull NativeWebRequest webRequest, + @Nullable WebDataBinderFactory binderFactory) { + validatePageableParameters(webRequest); + return super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + } + + private void validatePageableParameters(NativeWebRequest webRequest) { + Optional page = parseInteger(webRequest, PAGE_PARAM, + ValidationMessages.PAGE_INDEX_NOT_INTEGER); + Optional size = parseInteger(webRequest, SIZE_PARAM, + ValidationMessages.PAGE_SIZE_NOT_INTEGER); + if (page.isEmpty() && size.isEmpty()) { + return; + } + new PaginationCriteria(page.orElse(0), size.orElse(1), null).validateBounds(maxPageSize); + } + + private static Optional parseInteger(NativeWebRequest webRequest, String name, + String errorMessage) { + String raw = webRequest.getParameter(name); + if (raw == null) { + return Optional.empty(); + } + try { + return Optional.of(Integer.parseInt(raw)); + } catch (NumberFormatException _) { + throw new InvalidSearchQueryException(errorMessage); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java index cab1b62f..cc599401 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java @@ -1,6 +1,12 @@ package com.decathlon.idp_core.infrastructure.adapters.api.configuration; +import java.util.List; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -14,11 +20,24 @@ /// by making documentation the default landing page for the application. @Configuration +@EnableConfigurationProperties(SearchProperties.class) +@Order(Ordered.HIGHEST_PRECEDENCE) public class WebConfiguration implements WebMvcConfigurer { + private final int maxPageSize; + + public WebConfiguration(SearchProperties searchProperties) { + this.maxPageSize = searchProperties.maxPageSize(); + } + @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addRedirectViewController("/", "swagger-ui/index.html"); } + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new ValidatingPageableArgumentResolver(maxPageSize)); + } + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 15b8a8f4..dd774195 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -75,6 +75,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntityPatch; @@ -89,6 +90,7 @@ import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.search.SearchFilterParser; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SearchProperties; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityPatchDtoIn; @@ -133,6 +135,7 @@ public class EntityController { private final SearchFilterMapper searchFilterMapper; private final SearchFilterParser searchFilterParser; private final EntityGraphService entityGraphService; + private final SearchProperties searchProperties; // Validation message constants for relations_depth parameter private static final int RELATIONS_DEPTH_MIN = 1; @@ -170,13 +173,14 @@ public class EntityController { @ResponseStatus(OK) @GetMapping("/{templateIdentifier}") public Page getEntities(@RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(required = false) Integer size, @PathVariable String templateIdentifier, @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); + PaginationCriteria paginationCriteria = new PaginationCriteria(page, resolvePageSize(size), + null); EntityFilter filter = entityFilterDslParser.parse(q); // Single transaction: pagination + batch relation fetch in one DB round trip - Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, - templateIdentifier, filter, 1); + Page graphNodes = entityGraphService.getEntityGraphPageByTemplate( + paginationCriteria, templateIdentifier, filter, 1, searchProperties.maxPageSize()); return entityDtoOutFromEntityNodeMapper.toPageDto(graphNodes, templateIdentifier, 1); } @@ -377,10 +381,11 @@ public Page searchEntities(@RequestBody EntitySearchRequestDtoIn s RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); SearchFilterNode filter = searchFilterParser.parse(rawFilter); PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), - searchRequest.size(), searchRequest.sort()); + resolvePageSize(searchRequest.size()), searchRequest.sort()); + paginationCriteria.validateBounds(searchProperties.maxPageSize()); PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), - paginationCriteria); + paginationCriteria, searchProperties.maxPageSize()); Page page = toPageResponse(result.content(), paginationCriteria, result.totalElements()); return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(page); @@ -391,4 +396,12 @@ private Page toPageResponse(List content, PaginationCriteria criteria, Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); return new PageImpl<>(content, pageable, totalElements); } + + /// Uses the requested size when present; otherwise the default size, capped at + /// the configured maximum so that omitting `size` never fails validation. + private int resolvePageSize(Integer requestedSize) { + return requestedSize != null + ? requestedSize + : Math.min(SearchConstraints.DEFAULT_PAGE_SIZE, searchProperties.maxPageSize()); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java index 945e83a7..dbe840ba 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -44,9 +44,6 @@ public record EntitySearchRequestDtoIn( @Schema(description = SwaggerDescription.PARAM_SORT_DESCRIPTION, example = "identifier:asc") String sort) { public EntitySearchRequestDtoIn { - if (size == null) { - size = 20; - } if (page == null) { page = 0; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 697d0dc5..91fbb5aa 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -12,7 +12,6 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -24,6 +23,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; +import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; @@ -143,6 +143,23 @@ public ResponseEntity handleEntityTemplateIdentifierCannotChangeE return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } + /// Handles non-integer query or path parameters, such as `size=abc`. + /// + /// **HTTP mapping:** Maps Spring MethodArgumentTypeMismatchException to HTTP + /// 400 with a message naming the offending parameter, instead of the generic + /// HTTP 500 fallback. + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex) { + String message = switch (ex.getName()) { + case "page" -> ValidationMessages.PAGE_INDEX_NOT_INTEGER; + case "size" -> ValidationMessages.PAGE_SIZE_NOT_INTEGER; + default -> "Invalid value for parameter '%s'".formatted(ex.getName()); + }; + log.warn("Type mismatch for parameter '{}': {}", ex.getName(), ex.getValue()); + return createErrorResponse(HttpStatus.BAD_REQUEST, message); + } + /// Handles domain exception for wrong entity entityTemplateIdentifier property /// rules. /// diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java index 9bb53104..7a2368d0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java @@ -1,9 +1,16 @@ package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; import java.util.Collection; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.springframework.stereotype.Component; +import com.decathlon.idp_core.domain.constant.JsltConstraints; import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingJsltErrorException; import com.decathlon.idp_core.infrastructure.adapters.entity_mapping.engine.ExpressionEngine; import com.fasterxml.jackson.databind.JsonNode; @@ -11,9 +18,17 @@ import com.schibsted.spt.data.jslt.Function; import com.schibsted.spt.data.jslt.Parser; +/// JSLT expression engine with DoS protections: expression length is bounded and +/// each evaluation runs under a timeout. +/// +/// JSLT does not honor thread interruption, so a timed-out evaluation is +/// cancelled and may keep running on its virtual thread until it finishes. @Component public class JsltEngine implements ExpressionEngine { + private static final ExecutorService EVALUATION_EXECUTOR = Executors + .newVirtualThreadPerTaskExecutor(); + private final Collection customFunctions; // Creates a JSLT engine with the custom functions discovered by Spring. @@ -28,6 +43,7 @@ public Expression compile(String expression) { @Override public void validateExpression(String expression) { + checkExpressionLength(expression); try { compile(expression); } catch (Exception exception) { @@ -37,10 +53,27 @@ public void validateExpression(String expression) { @Override public JsonNode evaluate(String expression, JsonNode payload) { + checkExpressionLength(expression); + Future future = EVALUATION_EXECUTOR.submit(() -> compile(expression).apply(payload)); try { - return compile(expression).apply(payload); - } catch (Exception exception) { - throw new EntityDynamicMappingJsltErrorException(exception.getMessage()); + return future.get(JsltConstraints.EVALUATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException _) { + future.cancel(true); + throw new EntityDynamicMappingJsltErrorException( + "JSLT evaluation exceeded timeout of " + JsltConstraints.EVALUATION_TIMEOUT_MS + " ms"); + } catch (ExecutionException exception) { + throw new EntityDynamicMappingJsltErrorException(exception.getCause().getMessage()); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + future.cancel(true); + throw new EntityDynamicMappingJsltErrorException("JSLT evaluation was interrupted"); + } + } + + private void checkExpressionLength(String expression) { + if (expression != null && expression.length() > JsltConstraints.MAX_EXPRESSION_LENGTH) { + throw new EntityDynamicMappingJsltErrorException("JSLT expression exceeds maximum length of " + + JsltConstraints.MAX_EXPRESSION_LENGTH + " characters"); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapter.java index 4086832d..00141bdd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapter.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -10,6 +12,7 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import com.decathlon.idp_core.domain.constant.JsltConstraints; import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -17,7 +20,9 @@ import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.entity_mapping.RelationMapping; import com.decathlon.idp_core.domain.port.MappingEnginePort; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -216,12 +221,52 @@ public Map extractProperties(String rawPayload, .collect(Collectors.toUnmodifiableMap(Property::name, Property::value)); } - /// Parses raw JSON and translates parsing failures to a domain exception. + /// Enforces payload limits, then parses raw JSON and translates parsing + /// failures + /// to a domain exception. Size and depth are checked before the tree is built + /// so + /// oversized or deeply nested input is rejected cheaply. private JsonNode parsePayload(String rawPayload) { + checkPayloadSize(rawPayload); + checkJsonDepth(rawPayload); try { return objectMapper.readTree(rawPayload); } catch (JsonProcessingException e) { throw new EntityDynamicMappingConfigurationException("Failed to parse JSON payload", e); } } + + /// Rejects payloads whose UTF-8 encoding exceeds the configured maximum size. + private void checkPayloadSize(String rawPayload) { + if (rawPayload != null + && rawPayload.getBytes(StandardCharsets.UTF_8).length > JsltConstraints.MAX_PAYLOAD_BYTES) { + throw new EntityDynamicMappingConfigurationException( + "JSON payload exceeds maximum size of " + JsltConstraints.MAX_PAYLOAD_BYTES + " bytes"); + } + } + + /// Streams over the payload tokens and rejects nesting deeper than the + /// configured + /// maximum, without materializing the tree. + private void checkJsonDepth(String rawPayload) { + if (rawPayload == null) { + return; + } + try (JsonParser parser = objectMapper.getFactory().createParser(rawPayload)) { + int depth = 0; + for (JsonToken token = parser.nextToken(); token != null; token = parser.nextToken()) { + if (token.isStructStart()) { + depth++; + if (depth > JsltConstraints.MAX_JSON_DEPTH) { + throw new EntityDynamicMappingConfigurationException( + "JSON payload exceeds maximum depth of " + JsltConstraints.MAX_JSON_DEPTH); + } + } else if (token.isStructEnd()) { + depth--; + } + } + } catch (IOException e) { + throw new EntityDynamicMappingConfigurationException("Failed to parse JSON payload", e); + } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityConfiguration.java index ad63954f..2506e92d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityConfiguration.java @@ -1,10 +1,19 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.security; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -/// Enables binding of webhook security configuration properties. +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurityValidationPolicy; + +/// Enables binding and mapping of webhook security configuration properties. @Configuration @EnableConfigurationProperties(WebhookSecurityProperties.class) public class WebhookSecurityConfiguration { + + @Bean + public WebhookSecurityValidationPolicy webhookSecurityValidationPolicy( + WebhookSecurityProperties webhookSecurityProperties) { + return new WebhookSecurityValidationPolicy(webhookSecurityProperties.allowNone()); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityProperties.java index 11d44a4c..25ab7a01 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookSecurityProperties.java @@ -4,13 +4,15 @@ import java.util.Set; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; -/// Externalized JWKS security configuration for webhook authentication. +/// Externalized security configuration for webhook authentication. /// /// The `allowedJwksHosts` allow-list is intentionally small and explicit so teams can permit /// trusted issuers without hardcoding hostnames in the validator. @ConfigurationProperties(prefix = "idp.security.webhook") -public record WebhookSecurityProperties(Set allowedJwksHosts) { +public record WebhookSecurityProperties(Set allowedJwksHosts, + @DefaultValue("false") boolean allowNone) { public WebhookSecurityProperties { allowedJwksHosts = allowedJwksHosts == null diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83ac46c2..7f0fa20e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -111,6 +111,8 @@ springdoc: urls-primary-name: internal app: + # Maximum number of items returned per page by search and listing endpoints. + max-page-size: ${MAX_PAGE_SIZE:500} ingestion: webhook: base-path: /webhooks @@ -167,6 +169,9 @@ idp: # Comma-separated allow-list for trusted JWKS hosts. # Example: ALLOWED_JWKS_HOSTS=github.com,auth.decathlon.com allowed-jwks-hosts: ${ALLOWED_JWKS_HOSTS:} + # Allows webhook connectors with security type NONE (unauthenticated). Disabled by default. + # Example: IDP_WEBHOOK_SECURITY_ALLOW_NONE=true + allow-none: ${IDP_WEBHOOK_SECURITY_ALLOW_NONE:false} logging: level: diff --git a/src/test/java/com/decathlon/idp_core/architecture/HexagonalArchitectureTest.java b/src/test/java/com/decathlon/idp_core/architecture/HexagonalArchitectureTest.java index 75efd01c..80cf6ac9 100644 --- a/src/test/java/com/decathlon/idp_core/architecture/HexagonalArchitectureTest.java +++ b/src/test/java/com/decathlon/idp_core/architecture/HexagonalArchitectureTest.java @@ -43,7 +43,7 @@ class HexagonalArchitectureTest { "org.springframework.web..", "org.springframework.data.jpa..", "org.springframework.data.repository..", "org.springframework.http..", "org.hibernate..", "org.mapstruct..", "com.schibsted..", "com.fasterxml.jackson..", "org.apache.kafka..", - "java.net.http..",}; + "java.net.http..", "org.springframework.beans.."}; // --------------------------------------------------------------------------------------- // 1. Hexagonal constraints diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index f612aac7..1463e0fa 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -33,7 +33,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; -import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; @@ -450,7 +449,7 @@ private SearchFilterNode emptyFilter() { private void assertSearchThrows(PaginationCriteria paginationCriteria) { var filter = emptyFilter(); assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, paginationCriteria)); + () -> entityService.searchEntities(filter, null, paginationCriteria, 500)); } private Property property(String name, String value) { @@ -497,7 +496,8 @@ void shouldSearchEntitiesWithValidParameters() { when(entityRepository.search(filter, "api", new PaginationCriteria(0, 20, null))) .thenReturn(paginatedResult); - var result = entityService.searchEntities(filter, "api", new PaginationCriteria(0, 20, null)); + var result = entityService.searchEntities(filter, "api", new PaginationCriteria(0, 20, null), + 500); assertEquals(paginatedResult, result); verify(searchFilterValidationService).validate(filter, "api"); @@ -514,29 +514,11 @@ void shouldSearchEntitiesWithValidSort() { .thenReturn(paginatedResult); var result = entityService.searchEntities(filter, null, - new PaginationCriteria(0, 10, "identifier:asc")); + new PaginationCriteria(0, 10, "identifier:asc"), 500); assertEquals(paginatedResult, result); } - @Test - @DisplayName("Should reject page size exceeding maximum") - void shouldRejectPageSizeExceedingMaximum() { - assertSearchThrows(new PaginationCriteria(0, SearchConstraints.MAX_PAGE_SIZE + 1, null)); - } - - @Test - @DisplayName("Should reject negative page index") - void shouldRejectNegativePageIndex() { - assertSearchThrows(new PaginationCriteria(-1, 20, null)); - } - - @Test - @DisplayName("Should reject non-positive page size") - void shouldRejectNonPositivePageSize() { - assertSearchThrows(new PaginationCriteria(0, 0, null)); - } - @Test @DisplayName("Should reject invalid sort field") void shouldRejectInvalidSortField() { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java index 69611574..9b872d65 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java @@ -22,6 +22,7 @@ import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurityValidationPolicy; import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; @DisplayName("WebhookSecurityValidationService Tests") @@ -36,7 +37,8 @@ class WebhookSecurityValidationServiceTest { @BeforeEach void setUp() { lenient().when(hmacCreationValidator.supports(HMAC_SHA256)).thenReturn(true); - service = new WebhookSecurityValidationService(List.of(hmacCreationValidator)); + service = new WebhookSecurityValidationService(List.of(hmacCreationValidator), + new WebhookSecurityValidationPolicy(true)); } @Nested @@ -115,6 +117,40 @@ void shouldThrowForNoneTypeWithNonEmptyConfig() { .isInstanceOf(WebhookSecurityConfigurationException.class) .hasMessageContaining("must be empty when type is NONE"); } + + @Test + @DisplayName("Should reject NONE type when the allow-none flag is disabled") + void shouldRejectNoneTypeWhenDisabled() { + var disabled = new WebhookSecurityValidationService(List.of(hmacCreationValidator), + new WebhookSecurityValidationPolicy(false)); + var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of()); + + assertThatThrownBy(() -> disabled.validateForCreation(security)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("IDP_WEBHOOK_SECURITY_ALLOW_NONE"); + } + + @Test + @DisplayName("Should reject implicit NONE (null config normalized) when the flag is disabled") + void shouldRejectImplicitNoneWhenDisabled() { + var disabled = new WebhookSecurityValidationService(List.of(hmacCreationValidator), + new WebhookSecurityValidationPolicy(false)); + var security = new WebhookSecurity(WebhookSecurityType.NONE, null); + + assertThatThrownBy(() -> disabled.validateForCreation(security)) + .isInstanceOf(WebhookSecurityConfigurationException.class); + } + + @Test + @DisplayName("Should still validate other security types when the flag is disabled") + void shouldAllowHmacWhenNoneDisabled() { + var disabled = new WebhookSecurityValidationService(List.of(hmacCreationValidator), + new WebhookSecurityValidationPolicy(false)); + var config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET"); + + assertThatCode(() -> disabled.validateForCreation(new WebhookSecurity(HMAC_SHA256, config))) + .doesNotThrowAnyException(); + } } @Nested diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 0ec4a066..591b2924 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -30,6 +30,7 @@ import com.decathlon.idp_core.AbstractIntegrationTest; import com.decathlon.idp_core.domain.constant.SearchConstraints; +import com.decathlon.idp_core.domain.constant.ValidationMessages; /// Integration tests for the EntityController REST API endpoints. These tests /// verify the behavior of entity retrieval endpoints, including pagination, @@ -70,6 +71,76 @@ void getEntities_paginated_200() throws Exception { .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); } + @Test + @DisplayName("Should accept size at the minimum bound of 1") + @WithMockUser + void getEntities_sizeMinimum_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "1").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.page.size").value(1)); + } + + @Test + @DisplayName("Should accept size at the maximum bound of 500") + @WithMockUser + void getEntities_sizeMaximum_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "500").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.page.size").value(500)); + } + + @Test + @DisplayName("Should return 400 when size is zero") + @WithMockUser + void getEntities_sizeZero_400() throws Exception { + mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("page", "0").param("size", "0").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 when size exceeds 500") + @WithMockUser + void getEntities_sizeAboveMaximum_400() throws Exception { + mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("page", "0").param("size", "501").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 when page is negative") + @WithMockUser + void getEntities_negativePage_400() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("page", "-1").param("size", "10").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 when size is not a number") + @WithMockUser + void getEntities_nonNumericSize_400() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "abc").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect( + jsonPath("$.error_description").value(ValidationMessages.PAGE_SIZE_NOT_INTEGER)); + } + + @Test + @DisplayName("Should return 400 when page is not an integer") + @WithMockUser + void getEntities_nonIntegerPage_400() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("page", "1.5").param("size", "10").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect( + jsonPath("$.error_description").value(ValidationMessages.PAGE_INDEX_NOT_INTEGER)); + } + @Test @DisplayName("Should return 404 when template does not exist") @WithMockUser @@ -115,7 +186,7 @@ void getEntities_paginated_200_custom() throws Exception { @Test @DisplayName("Should return paginated entities with default pagination") @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { + void getEntities_defaultPagination_200() throws Exception { mockMvc .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) .accept(APPLICATION_JSON)) @@ -1600,7 +1671,7 @@ void search_200_numericLte_onNumberProperty_allMatch() throws Exception { void search_200_noPageOrSize_usesDefaults() throws Exception { // Omitting page and size must not cause a 400 JSON parse error (primitive int // vs null). - // The record defaults should kick in: page=0, size=20. + // The controller defaults should kick in: page=0, size=20. mockMvc .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) .accept(APPLICATION_JSON).with(csrf()).content(""" @@ -1626,8 +1697,8 @@ void search_400_pageSizeTooLarge() throws Exception { .accept(APPLICATION_JSON).with(csrf()).content(""" { "page": 0, "size": 501 } """)) - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") - .value("Page size must not exceed %d".formatted(SearchConstraints.MAX_PAGE_SIZE))); + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description").value( + "Page size must not exceed %d".formatted(SearchConstraints.DEFAULT_MAX_PAGE_SIZE))); } @Test diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java index c2c18cf7..f72fb37b 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -137,6 +137,35 @@ void getMappings_200_with_custom_pagination() throws Exception { .andExpect(jsonPath("$.page.size").value(5)) .andExpect(jsonPath("$.page.number").value(0)); } + + @Test + @WithMockUser + @DisplayName("Should return 400 when page size exceeds 500") + void getMappings_400_size_above_500() throws Exception { + mockMvc + .perform( + get(MAPPING_PATH).param("page", "0").param("size", "501").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when page size is zero") + void getMappings_400_size_zero() throws Exception { + mockMvc + .perform(get(MAPPING_PATH).param("page", "0").param("size", "0").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when page size is not an integer") + void getMappings_400_size_not_integer() throws Exception { + mockMvc + .perform( + get(MAPPING_PATH).param("page", "0").param("size", "abc").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } } @Nested diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index d9833076..008bd8f6 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -124,6 +124,55 @@ void getTemplates_paginated_200_custom() throws Exception { .andExpect(jsonPath("$.page.number").value(1)); } + /// Tests that Pageable endpoints reject `size` values above 500 instead of + /// clamping them. + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 400 when page size exceeds 500 on Pageable endpoints") + @WithMockUser + void getTemplates_paginated_400_size_above_500() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates").param("page", "0").param("size", "501") + .accept(APPLICATION_JSON)).andExpect(status().isBadRequest()); + } + + /// Tests that Pageable endpoints reject `size` values below 1 instead of + /// silently replacing them with a default. + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 400 when page size is zero on Pageable endpoints") + @WithMockUser + void getTemplates_paginated_400_size_zero() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates").param("page", "0").param("size", "0") + .accept(APPLICATION_JSON)).andExpect(status().isBadRequest()); + } + + /// Tests that Pageable endpoints reject non-numeric `size` values. + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 400 when page size is not an integer on Pageable endpoints") + @WithMockUser + void getTemplates_paginated_400_size_not_integer() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates").param("page", "0").param("size", "abc") + .accept(APPLICATION_JSON)).andExpect(status().isBadRequest()); + } + + /// Tests that the maximum allowed page size of 500 is accepted on Pageable + /// endpoints. + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should accept page size of exactly 500 on Pageable endpoints") + @WithMockUser + void getTemplates_paginated_200_size_500() throws Exception { + + mockMvc + .perform(get("/api/v1/entity-templates").param("page", "0").param("size", "500") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.page.size").value(500)); + } + /// Tests the GET /api/v1/entity-templates/{identifier} endpoint for /// retrieving a specific template. /// This test verifies that: diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java index df8a9e06..efd79728 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -168,6 +168,30 @@ void getWebhooks_200_with_data() throws Exception { .andExpect(jsonPath("$.content[*].identifier", containsInAnyOrder("github-dora-connector", "github-dora-connector-success", "public-connector", "token-connector"))); } + + @Test + @WithMockUser + @DisplayName("Should return 400 when page size exceeds 500") + void getWebhooks_400_size_above_500() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).param("size", "501").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when page size is zero") + void getWebhooks_400_size_zero() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).param("size", "0").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when page size is not an integer") + void getWebhooks_400_size_not_integer() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).param("size", "abc").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } } @Nested diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngineTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngineTest.java new file mode 100644 index 00000000..d14f24db --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngineTest.java @@ -0,0 +1,97 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.JsltConstraints; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingJsltErrorException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.schibsted.spt.data.jslt.Function; + +/// Unit tests for the DoS protections of JsltEngine: expression length and evaluation timeout. +@DisplayName("JsltEngine") +class JsltEngineTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should evaluate a valid expression") + void shouldEvaluateValidExpression() throws Exception { + var engine = new JsltEngine(List.of()); + JsonNode payload = objectMapper.readTree("{\"name\":\"repo\"}"); + + JsonNode result = engine.evaluate(".name", payload); + + assertThat(result.asText()).isEqualTo("repo"); + } + + @Test + @DisplayName("Should reject an expression longer than the maximum length on validation") + void shouldRejectOversizedExpressionOnValidation() { + var engine = new JsltEngine(List.of()); + String expression = oversizedExpression(); + + assertThatThrownBy(() -> engine.validateExpression(expression)) + .isInstanceOf(EntityDynamicMappingJsltErrorException.class) + .hasMessageContaining("maximum length"); + } + + @Test + @DisplayName("Should reject an expression longer than the maximum length on evaluation") + void shouldRejectOversizedExpressionOnEvaluation() throws Exception { + var engine = new JsltEngine(List.of()); + JsonNode payload = objectMapper.readTree("{}"); + String expression = oversizedExpression(); + + assertThatThrownBy(() -> engine.evaluate(expression, payload)) + .isInstanceOf(EntityDynamicMappingJsltErrorException.class) + .hasMessageContaining("maximum length"); + } + + @Test + @DisplayName("Should fail evaluation that exceeds the evaluation timeout") + void shouldFailEvaluationExceedingTimeout() throws Exception { + var engine = new JsltEngine(List.of(new SlowFunction())); + JsonNode payload = objectMapper.readTree("{}"); + + assertThatThrownBy(() -> engine.evaluate("slow()", payload)) + .isInstanceOf(EntityDynamicMappingJsltErrorException.class) + .hasMessageContaining("exceeded timeout"); + } + + private static String oversizedExpression() { + return "\"" + "a".repeat(JsltConstraints.MAX_EXPRESSION_LENGTH) + "\""; + } + + private static final class SlowFunction implements Function { + + @Override + public String getName() { + return "slow"; + } + + @Override + public int getMinArguments() { + return 0; + } + + @Override + public int getMaxArguments() { + return 0; + } + + @Override + public JsonNode call(JsonNode input, JsonNode[] arguments) { + LockSupport.parkNanos(Duration.ofSeconds(30).toNanos()); + return input; + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java index 9a73291e..76894dfc 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java @@ -1,6 +1,8 @@ package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Collections; import java.util.List; @@ -10,6 +12,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.decathlon.idp_core.domain.constant.JsltConstraints; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction; import com.decathlon.idp_core.domain.model.entity_mapping.RelationMapping; @@ -120,4 +124,45 @@ void shouldExtractRelationFromArrayOfObjects() { .containsExactly("398cb790-9117-4bfe-830e-bbb0e423c26e"); } + @Test + @DisplayName("Should reject a payload larger than the maximum payload size") + void shouldRejectOversizedPayload() { + var mapping = new EntityDynamicMapping(null, "mapping", "microservice", ".action == \"pushed\"", + MappingAction.UPDATE_ENTITY, "Mapping", "desc", ".repository.full_name", ".repository.name", + Map.of(), List.of()); + var payload = "{\"data\":\"" + "a".repeat(JsltConstraints.MAX_PAYLOAD_BYTES) + "\"}"; + + assertThatThrownBy(() -> adapter.mapToEntity(payload, mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("maximum size"); + } + + @Test + @DisplayName("Should accept a payload nested at the maximum depth") + void shouldAcceptPayloadAtMaximumDepth() { + var mapping = new EntityDynamicMapping(null, "mapping", "microservice", ".action == \"pushed\"", + MappingAction.UPDATE_ENTITY, "Mapping", "desc", ".repository.full_name", ".repository.name", + Map.of(), List.of()); + var payload = nestedObject(JsltConstraints.MAX_JSON_DEPTH); + + assertThatCode(() -> adapter.mapToEntity(payload, mapping)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should reject a payload nested beyond the maximum depth") + void shouldRejectPayloadBeyondMaximumDepth() { + var mapping = new EntityDynamicMapping(null, "mapping", "microservice", ".action == \"pushed\"", + MappingAction.UPDATE_ENTITY, "Mapping", "desc", ".repository.full_name", ".repository.name", + Map.of(), List.of()); + var payload = nestedObject(JsltConstraints.MAX_JSON_DEPTH + 1); + + assertThatThrownBy(() -> adapter.mapToEntity(payload, mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("maximum depth"); + } + + private static String nestedObject(int depth) { + return "{\"a\":".repeat(depth) + "1" + "}".repeat(depth); + } + } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java index 192ce240..f0b0e256 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java @@ -135,7 +135,7 @@ void shouldThrowWhenJwksUriPointsToPrivateIpAddress() { @DisplayName("Should accept a host when it is allow-listed in security properties") void shouldAcceptAllowListedHost() { JwtBearerSecurityValidator safeValidator = new JwtBearerSecurityValidator(jwtDecoderProvider, - new WebhookSecurityProperties(Set.of("github.com"))) { + new WebhookSecurityProperties(Set.of("github.com"), false)) { @Override protected InetAddress[] resolveHostAddresses(String host) { throw new AssertionError("JWKS host validation should not run for allow-listed hosts"); @@ -152,7 +152,7 @@ protected InetAddress[] resolveHostAddresses(String host) { @DisplayName("Should reject a host when it is not allow-listed in security properties") void shouldRejectNonAllowListedHost() { JwtBearerSecurityValidator safeValidator = new JwtBearerSecurityValidator(jwtDecoderProvider, - new WebhookSecurityProperties(Set.of("auth.decathlon.com"))) { + new WebhookSecurityProperties(Set.of("auth.decathlon.com"), false)) { @Override protected InetAddress[] resolveHostAddresses(String host) { throw new AssertionError( diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookJwtDecoderProviderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookJwtDecoderProviderTest.java index 436231f2..3440d0bd 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookJwtDecoderProviderTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/WebhookJwtDecoderProviderTest.java @@ -31,7 +31,7 @@ void buildSecureRestTemplate_usesApacheHttpClientWithSecureDnsResolver() throws @DisplayName("accepts an allow-listed localhost JWKS host at runtime") void get_acceptsAllowListedLocalhost() { WebhookJwtDecoderProvider provider = new WebhookJwtDecoderProvider( - new WebhookSecurityProperties(Set.of("localhost"))); + new WebhookSecurityProperties(Set.of("localhost"), false)); assertThatCode(() -> provider.get("https://localhost/.well-known/jwks.json")) .doesNotThrowAnyException(); @@ -41,7 +41,7 @@ void get_acceptsAllowListedLocalhost() { @DisplayName("rejects a runtime JWKS host that is not allow-listed") void get_rejectsNonAllowListedHost() { WebhookJwtDecoderProvider provider = new WebhookJwtDecoderProvider( - new WebhookSecurityProperties(Set.of("auth.decathlon.com"))); + new WebhookSecurityProperties(Set.of("auth.decathlon.com"), false)); assertThatThrownBy(() -> provider.get("https://github.com/.well-known/jwks.json")) .isInstanceOf(WebhookJwksHostForbiddenException.class); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 22564216..a6025fa3 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -85,3 +85,4 @@ idp: # Comma-separated allow-list for trusted JWKS hosts. # Example: ALLOWED_JWKS_HOSTS=github.com,auth.decathlon.com allowed-jwks-hosts: github.com,www.googleapis.com + allow-none: true