SolrSearchInputValidator.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service;

import jakarta.annotation.PostConstruct;
import org.entrystore.repository.util.LangFacetValue;
import org.entrystore.rest.springboot.model.api.FacetSettingsRequestParams;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.net.URLDecoder;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * Boundary validation for the Solr-backed {@code /search?type=solr} endpoint, which is guest-accessible.
 *
 * <p>{@code sort} and {@code facetFields} accept any plain field name, including dynamic ones such as
 * {@code metadata.predicate.integer.<hash>}; Solr itself answers an unknown field with 400. Two shapes are
 * refused: anything but a plain name (see {@link #PLAIN_FIELD}), because Solr reads local params such as
 * {@code {!facet.matches=...}} in a facet field and lets them override the request, and the server-internal
 * language companion {@code [related.]metadata.predicate.literal_l.*} (see {@code LanguageAwareFacets}).
 * {@code facetFields} also refuses the catch-all text fields in {@link #UNFACETABLE_FIELDS}.
 *
 * <p>{@code facetMatches} and {@code facetLang} are restricted to literal-only patterns because Solr evaluates
 * them as regular expressions, so a crafted pattern could tie up Solr (ReDoS). The length and count caps are
 * opt-in ({@code entrystore.solr.search.*}, 0 by default, which means unlimited). Violations throw
 * {@link BadRequestException}, which {@code AppExceptionHandler} maps to 400.
 */
@Service
public class SolrSearchInputValidator {

	/**
	 * A plain Solr field name, which covers every field the index declares, e.g. {@code title.sv-SE} and
	 * {@code related.metadata.predicate.literal_s.<hash>}. Admitting braces, exclamation marks, equals signs or
	 * spaces would let local params through, which override facet.matches, facet.limit and the response key.
	 */
	private static final Pattern PLAIN_FIELD = Pattern.compile("^[A-Za-z0-9_.\\-]+$");

	/**
	 * Tokenized catch-all fields without docValues. Faceting on them makes Solr uninvert every token of every
	 * document onto its heap after each commit, and their buckets are word fragments no client uses.
	 */
	private static final Set<String> UNFACETABLE_FIELDS = Set.of("all", "fulltext", "metadata.object.literal");

	/**
	 * Literal text for {@code facetMatches}, which Solr evaluates as a full-match regular expression: any character
	 * but a regex metacharacter or a control character, or a metacharacter escaped with a backslash, so
	 * {@code Dr\. Smith} matches "Dr. Smith" as it did in 5.x. Letters of every script pass. Admitting an unescaped
	 * metacharacter (quantifiers, groups, alternation, classes) reopens the ReDoS surface. Control characters are
	 * refused because U+001F separates label and language in the companion terms that {@code facetLang} matches.
	 */
	private static final Pattern FACET_MATCHES =
			Pattern.compile("^(?:[^\\\\^$.|?*+()\\[\\]{}\\p{Cc}]|\\\\[\\\\^$.|?*+()\\[\\]{}])+$");

	/** Checked before {@link #FACET_MATCHES}, whose repeated group recurses once per character. */
	private static final int MAX_FACET_MATCHES_LENGTH = 256;

	/**
	 * Constrains {@code facetLang} to the BCP 47 alphabet (alphanumerics and dash, at most 35 characters, the
	 * length limit of a well-formed tag). The value is embedded verbatim in the {@code facet.matches} regex that
	 * Solr evaluates on the language companion field. Any change to this pattern must keep it literal-only, like
	 * {@link #FACET_MATCHES}: admitting regex metacharacters (e.g. a {@code *-CH} wildcard) reopens the ReDoS and
	 * regex-injection surface.
	 */
	private static final Pattern FACET_LANG = Pattern.compile("^[A-Za-z0-9-]{1,35}$");

	@Value("${entrystore.solr.search.query.max-length:0}")
	private int maxQueryLength;

	@Value("${entrystore.solr.search.sort.max-length:0}")
	private int maxSortLength;

	@Value("${entrystore.solr.search.filter-query.max-length:0}")
	private int maxFilterQueryLength;

	@Value("${entrystore.solr.search.filter-query.max-count:0}")
	private int maxFilterQueryCount;

	@Value("${entrystore.solr.search.facet-fields.max-length:0}")
	private int maxFacetFieldsLength;

	@Value("${entrystore.solr.search.facet-fields.max-count:0}")
	private int maxFacetFieldCount;

	/**
	 * Fails startup on a negative limit, which would otherwise silently act as "unlimited".
	 */
	@PostConstruct
	void checkLimits() {
		requireNonNegative(maxQueryLength, "entrystore.solr.search.query.max-length");
		requireNonNegative(maxSortLength, "entrystore.solr.search.sort.max-length");
		requireNonNegative(maxFilterQueryLength, "entrystore.solr.search.filter-query.max-length");
		requireNonNegative(maxFilterQueryCount, "entrystore.solr.search.filter-query.max-count");
		requireNonNegative(maxFacetFieldsLength, "entrystore.solr.search.facet-fields.max-length");
		requireNonNegative(maxFacetFieldCount, "entrystore.solr.search.facet-fields.max-count");
	}

	private static void requireNonNegative(int limit, String key) {
		if (limit < 0) {
			throw new IllegalStateException(key + " must be 0 (unlimited) or positive, got " + limit);
		}
	}

	/** A limit of 0 means unlimited. */
	private static boolean exceeds(int value, int limit) {
		return limit > 0 && value > limit;
	}

	public void validateQuery(String query) {
		if (query != null && exceeds(query.length(), maxQueryLength)) {
			throw new BadRequestException(
					"Query parameter 'query' exceeds maximum length of " + maxQueryLength);
		}
	}

	public void validateSort(String sort) {
		if (sort == null || sort.isEmpty()) {
			return;
		}
		if (exceeds(sort.length(), maxSortLength)) {
			throw new BadRequestException(
					"Query parameter 'sort' exceeds maximum length of " + maxSortLength);
		}
		for (String token : sort.split("[,\\s]+")) {
			if (!token.isEmpty()) {
				requirePlainField(token, "sort");
			}
		}
	}

	/**
	 * Splits the raw {@code filterQuery} parameter into individual Solr filter queries and validates them.
	 * The comma separator is matched before URL-decoding so that an unencoded comma separates filter
	 * queries while an encoded one ({@code %2C}) stays inside a filter query.
	 *
	 * @return the decoded filter queries; empty when the parameter is absent (a blank value yields one empty query)
	 * @throws BadRequestException if the raw value exceeds {@code entrystore.solr.search.filter-query.max-length} or
	 *                             splits into more than {@code entrystore.solr.search.filter-query.max-count} entries,
	 *                             where a limit of 0 means unlimited
	 */
	public List<String> parseFilterQueries(String rawFilterQuery) {
		List<String> filterQueries = rawFilterQuery == null
				? List.of()
				: Arrays.stream(rawFilterQuery.split(",")).map(fq -> URLDecoder.decode(fq, UTF_8)).toList();
		validateFilterQueries(filterQueries, rawFilterQuery);
		return filterQueries;
	}

	void validateFilterQueries(List<String> filterQueries, String rawFilterQuery) {
		if (rawFilterQuery == null || rawFilterQuery.isEmpty()) {
			return;
		}
		if (exceeds(rawFilterQuery.length(), maxFilterQueryLength)) {
			throw new BadRequestException(
					"Query parameter 'filterQuery' exceeds maximum length of " + maxFilterQueryLength);
		}
		if (exceeds(filterQueries.size(), maxFilterQueryCount)) {
			throw new BadRequestException(
					"Query parameter 'filterQuery' contains more than " + maxFilterQueryCount + " entries");
		}
		// Per-entry backstop. Unreachable via parseFilterQueries (decode only shrinks the already
		// length-capped raw value); kept for same-package callers passing pre-decoded entries.
		for (String fq : filterQueries) {
			if (fq != null && exceeds(fq.length(), maxFilterQueryLength)) {
				throw new BadRequestException(
						"Query parameter 'filterQuery' entry exceeds maximum length of " + maxFilterQueryLength);
			}
		}
	}

	public void validateFacetSettings(FacetSettingsRequestParams request) {
		String facetFields = request.getFacetFields();
		String facetMatches = request.getFacetMatches();
		validateFacetFields(facetFields);
		validateFacetMatches(facetMatches, facetFields);
		validateFacetLang(request.getFacetLang(), facetFields);
	}

	private void validateFacetFields(String facetFields) {
		if (facetFields == null || facetFields.isEmpty()) {
			return;
		}
		if (exceeds(facetFields.length(), maxFacetFieldsLength)) {
			throw new BadRequestException(
					"Query parameter 'facetFields' exceeds maximum length of " + maxFacetFieldsLength);
		}
		String[] fields = facetFields.split(",");
		if (exceeds(fields.length, maxFacetFieldCount)) {
			throw new BadRequestException(
					"Query parameter 'facetFields' contains more than " + maxFacetFieldCount + " entries");
		}
		for (String field : fields) {
			String clientField = LanguageAwareFacets.clientField(field);
			if (!clientField.isEmpty()) {
				requirePlainField(clientField, "facetFields");
				if (UNFACETABLE_FIELDS.contains(clientField)) {
					throw new BadRequestException("Field '" + clientField + "' is not permitted in 'facetFields'");
				}
			}
		}
	}

	/** Without facetFields, facetMatches never reaches Solr, so it is ignored. */
	private static void validateFacetMatches(String matches, String facetFields) {
		if (matches == null || matches.isEmpty() || facetFields == null || facetFields.isEmpty()) {
			return;
		}
		if (matches.length() > MAX_FACET_MATCHES_LENGTH || !FACET_MATCHES.matcher(matches).matches()) {
			throw new BadRequestException("Query parameter 'facetMatches' must be literal text of at most "
					+ MAX_FACET_MATCHES_LENGTH + " characters; escape any of \\^$.|?*+()[]{} with a backslash");
		}
	}

	private static void validateFacetLang(String facetLang, String facetFields) {
		if (facetLang == null || facetLang.isEmpty()) {
			// An empty value is equivalent to omitting the parameter, like an empty facetMatches.
			return;
		}
		if (facetFields == null || facetFields.isEmpty()) {
			throw new BadRequestException(
					"Query parameter 'facetLang' requires 'facetFields' to be set");
		}
		if (!FACET_LANG.matcher(facetLang).matches()) {
			throw new BadRequestException(
					"Query parameter 'facetLang' must be a BCP 47 language tag matching pattern " + FACET_LANG.pattern());
		}
	}

	private static void requirePlainField(String field, String parameterName) {
		if (!PLAIN_FIELD.matcher(field).matches() || LangFacetValue.isLangFacetField(field)) {
			throw new BadRequestException(
					"Field '" + field + "' is not permitted in '" + parameterName + "'");
		}
	}
}