UriValidator.java

/*
 * Copyright 2021 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.keycloak.validate.validators;

import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * URI validation - accepts {@link URI}, {@link URL} and single String. Null input is valid, use other validators (like
 * {@link NotBlankValidator} or {@link NotEmptyValidator} to force field as required.
 */
public class UriValidator extends AbstractSimpleValidator implements ConfiguredProvider {

    public static final UriValidator INSTANCE = new UriValidator();

    public static final String KEY_ALLOWED_SCHEMES = "allowedSchemes";
    public static final String KEY_ALLOW_FRAGMENT = "allowFragment";
    public static final String KEY_REQUIRE_VALID_URL = "requireValidUrl";

    public static final List<String> DEFAULT_ALLOWED_SCHEMES = Collections.unmodifiableList(Arrays.asList(
            "http",
            "https"
    ));
    public static final String MESSAGE_INVALID_URI = "error-invalid-uri";
    public static final String MESSAGE_INVALID_SCHEME = "error-invalid-uri-scheme";
    public static final String MESSAGE_INVALID_FRAGMENT = "error-invalid-uri-fragment";

    public static final boolean DEFAULT_ALLOW_FRAGMENT = true;

    public static final boolean DEFAULT_REQUIRE_VALID_URL = true;

    public static final String ID = "uri";

    private static final List<ProviderConfigProperty> configProperties = new ArrayList<>();

    static {
        ProviderConfigProperty property;
        property = new ProviderConfigProperty();
        property.setName(KEY_ALLOWED_SCHEMES);
        property.setLabel("Allowed schemes");
        property.setHelpText("Allowed URL schemes. Defaults to 'http' and 'https' as only allowed schemes");
        property.setType(ProviderConfigProperty.MULTIVALUED_STRING_TYPE);
        property.setDefaultValue(DEFAULT_ALLOWED_SCHEMES);
        configProperties.add(property);

        property = new ProviderConfigProperty();
        property.setName(KEY_ALLOW_FRAGMENT);
        property.setLabel("Allow fragment");
        property.setHelpText("Specify if allow URL with the URI fragment. It is true by default");
        property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
        property.setDefaultValue(DEFAULT_ALLOW_FRAGMENT);
        configProperties.add(property);

        property = new ProviderConfigProperty();
        property.setName(KEY_REQUIRE_VALID_URL);
        property.setLabel("Require Valid URL");
        property.setHelpText("Checks if the specified URL is valid URL. It is true by default");
        property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
        property.setDefaultValue(DEFAULT_REQUIRE_VALID_URL);
        configProperties.add(property);
    }

    @Override
    public String getId() {
        return ID;
    }

    @Override
    protected boolean skipValidation(Object value, ValidatorConfig config) {
        return false;
    }

    @Override
    protected void doValidate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
    	
    	if (input == null || (input instanceof String && ((String) input).isEmpty())) {
            return;
    	}

        try {
            URI uri = toUri(input);

            if (uri == null) {
                context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_URI, input));
            } else {
                Set<String> allowedSchemes = new HashSet<>(config.getStringListOrDefault(KEY_ALLOWED_SCHEMES, DEFAULT_ALLOWED_SCHEMES));
                boolean allowFragment = config.getBooleanOrDefault(KEY_ALLOW_FRAGMENT, DEFAULT_ALLOW_FRAGMENT);
                boolean requireValidUrl = config.getBooleanOrDefault(KEY_REQUIRE_VALID_URL, DEFAULT_REQUIRE_VALID_URL);

                validateUri(uri, inputHint, context, allowedSchemes, allowFragment, requireValidUrl);
            }
        } catch (MalformedURLException | IllegalArgumentException | URISyntaxException e) {
            context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_URI, input, e.getMessage()));
        }
    }

    private URI toUri(Object input) throws URISyntaxException {

        if (input instanceof String uriString) {
            return new URI(uriString);
        } else if (input instanceof URI uri) {
            return uri;
        } else if (input instanceof URL url) {
            return url.toURI();
        }

        return null;
    }

    public boolean validateUri(URI uri, Set<String> allowedSchemes, boolean allowFragment, boolean requireValidUrl) {
        try {
            return validateUri(uri, "url", new ValidationContext(), allowedSchemes, allowFragment, requireValidUrl);
        } catch (MalformedURLException mue) {
            return false;
        }
    }

    public boolean validateUri(URI uri, String inputHint, ValidationContext context,
                               Set<String> allowedSchemes, boolean allowFragment, boolean requireValidUrl)
            throws MalformedURLException {

        boolean valid = true;
        if (uri.getScheme() != null && !allowedSchemes.contains(uri.getScheme())) {
            context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_SCHEME, uri, uri.getScheme()));
            valid = false;
        }

        if (!allowFragment && uri.getFragment() != null) {
            context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_FRAGMENT, uri, uri.getFragment()));
            valid = false;
        }

        // Don't check if URL is valid if there are other problems with it; otherwise it could lead to duplicate errors.
        // This cannot be moved higher because it acts on differently based on environment (e.g. sometimes it checks
        // scheme, sometimes it doesn't).
        if (requireValidUrl && valid) {
            URL ignored = uri.toURL(); // throws an exception
        }

        return valid;
    }

    @Override
    public String getHelpText() {
        return "Uri Validator";
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return configProperties;
    }
}