From 94aa25cce58bc7274e1b346933c94bc405dfe3f8 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:29:02 +0200 Subject: [PATCH 01/14] feat(security): add the SecurityProfile mode set and wire the posture resolver --- .../gateway/config/RouteTableBuilder.java | 36 +++- .../config/model/SecurityDefaultsConfig.java | 14 +- .../config/model/SecurityFilterConfig.java | 11 +- .../gateway/config/model/SecurityProfile.java | 137 ++++++++++++++++ .../gateway/edge/GatewayEdgeRoute.java | 95 ++++++++++- .../gateway/edge/RouteRuntimeAssembler.java | 58 +++++-- .../sheriff/gateway/routing/RouteRuntime.java | 19 ++- .../resources/schema/endpoint.schema.json | 2 +- .../main/resources/schema/gateway.schema.json | 5 +- .../gateway/config/RouteTableBuilderTest.java | 51 ++++++ .../gateway/config/load/ConfigLoaderTest.java | 129 +++++++++++++++ .../config/model/SecurityProfileTest.java | 154 ++++++++++++++++++ .../gateway/edge/GatewayEdgePipelineTest.java | 30 ++-- .../gateway/edge/GatewayEdgeRouteTest.java | 150 +++++++++++++++++ .../gateway/edge/GrpcDispatchStageTest.java | 6 +- .../edge/RouteRuntimeAssemblerTest.java | 91 ++++++++++- .../gateway/routing/RouteRuntimeTest.java | 48 ++++++ 17 files changed, 989 insertions(+), 47 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/SecurityProfileTest.java diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java index 0b32aac1..f5eceb77 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java @@ -42,8 +42,10 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteConfig; import de.cuioss.sheriff.gateway.config.model.RouteTable; +import de.cuioss.sheriff.gateway.config.model.SecurityDefaultsConfig; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; import de.cuioss.sheriff.gateway.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.config.model.UpstreamConfig; import de.cuioss.sheriff.gateway.config.model.UpstreamDefaultsConfig; import de.cuioss.sheriff.gateway.config.model.WebSocketConfig; @@ -81,7 +83,7 @@ public final class RouteTableBuilder { private static final List STANDARD_ALLOWED_METHODS = List.copyOf(EnumSet.allOf(HttpMethod.class)); /** The {@link AuthConfig#require()} value meaning no authentication is required; also the - * display fallback for an absent anchor name / security-filter profile in {@link #logPosture}. */ + * display fallback for an absent anchor name in {@link #logPosture}. */ private static final String NONE = "none"; /** The default {@code websocket.idle_timeout_seconds} applied when a WebSocket route omits it. */ @@ -199,7 +201,7 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou builder.upstream(Optional.of(applyRouteUpstreamPath(upstream, route))); } ResolvedRoute resolved = builder.build(); - logPosture(resolved); + logPosture(resolved, globalProfile(gateway)); return resolved; } @@ -280,11 +282,35 @@ private static AccessLevel effectiveAccessLevel(Optional anchor, A return anchor.map(AnchorConfig::access).orElse(AccessLevel.PUBLIC); } - private static void logPosture(ResolvedRoute route) { + /** + * Emits the route's boot posture line. The profile logged is the resolved effective + * one — the route's own {@code security_filter.profile} when declared, otherwise the + * gateway-wide {@code security_defaults} fallback. It is deliberately NOT the raw declared + * value with a {@code "none"} placeholder for unset: {@code none} is now a real mode, so that + * placeholder would report a {@code none}-mode posture for every route that merely omits the knob. + */ + private static void logPosture(ResolvedRoute route, SecurityProfile globalProfile) { String anchorName = route.anchor().orElse(NONE); - String filter = route.effectiveSecurityFilter().flatMap(SecurityFilterConfig::profile).orElse(NONE); + SecurityProfile effectiveProfile = route.effectiveSecurityFilter() + .flatMap(SecurityFilterConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(globalProfile); LOGGER.info(ConfigLogMessages.INFO.ROUTE_POSTURE, route.id(), anchorName, route.effectiveAuth().require(), - filter); + effectiveProfile.name().toLowerCase(Locale.ROOT)); + } + + /** + * The gateway-wide effective profile: the declared {@code security_defaults.profile}, or + * {@link SecurityProfile#DEFAULT_PROFILE} when the block (or the knob) is omitted. + * + * @param gateway the bound gateway document + * @return the resolved gateway-wide profile + */ + private static SecurityProfile globalProfile(GatewayConfig gateway) { + return gateway.securityDefaults() + .flatMap(SecurityDefaultsConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(SecurityProfile.DEFAULT_PROFILE); } private static void warnOnWeakeningOverride(RouteConfig route, EndpointConfig endpoint, diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java index a98cb2d0..6be52e91 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java @@ -21,10 +21,16 @@ /** * The global {@code security_defaults} block of {@code gateway.yaml}. *

- * {@code profile} selects the inbound-filter baseline preset - * ({@code default} / {@code strict} / {@code lenient}) applied to every route - * that does not declare its own {@code security_filter}. The value set is - * enforced by the configuration validator, not by the model. + * {@code profile} selects the gateway-wide inbound-filter mode — {@code strict} / + * {@code lenient} / {@code none}, see {@link SecurityProfile}. The value range is enforced by the + * bundled JSON Schema (an unrecognized value, the dropped {@code default} preset included, fails + * boot there), not by this model and not by the configuration validator. + *

+ * Fallback. Every route resolves its effective profile through + * {@code route → endpoint → anchor security_filter → security_defaults}. A route that declares no + * {@code security_filter} — or one whose block omits {@code profile} — therefore inherits the value + * carried here, and an entirely omitted {@code security_defaults} block resolves to + * {@link SecurityProfile#DEFAULT_PROFILE}. * * @param profile the baseline security-filter profile, empty when omitted * @author API Sheriff Team diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java index 4fca5f3c..ba3e6947 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java @@ -25,9 +25,16 @@ * The per-route {@code security_filter} block: allowlists and limits (explicitly * not a WAF). The optional baseline {@code profile} is overridden by the limits * below. + *

+ * An omitted {@code profile} inherits the gateway-wide value from + * {@code security_defaults} (which itself resolves to {@link SecurityProfile#DEFAULT_PROFILE} when + * omitted). A {@code none} profile disables only the url-parameter name/value validation and the + * per-route pipeline re-run — the limits below, including {@code max_body_bytes}, keep being + * enforced against the nearest non-{@code none} profile's preset. * - * @param profile the baseline preset, empty when the global default - * applies + * @param profile the inbound-filter mode — {@code strict} / {@code lenient} / + * {@code none}, see {@link SecurityProfile} — empty when the + * {@code security_defaults} fallback applies * @param allowedPaths the path allowlist entries, empty when none * @param maxHeaderCount the maximum header count, empty when omitted * @param maxHeaderValueLength the maximum single-header-value length in diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java new file mode 100644 index 00000000..d659c547 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java @@ -0,0 +1,137 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.gateway.config.model; + +import java.util.Locale; +import java.util.Optional; + + +import de.cuioss.http.security.config.SecurityConfiguration; + +import org.jspecify.annotations.Nullable; + +/** + * The canonical inbound-filter mode set — the value space of the {@code profile} knob on both + * {@code security_defaults} and every {@code security_filter} block. The value range itself is + * gated by the bundled JSON Schema (three symmetric enum sites), so this enum never sees an + * unrecognized value at boot; {@link #parse(String)} exists for the model layer and for tests. + *

+ * Two contracts this enum makes explicit. + *

    + *
  1. An omitted block resolves to {@link #STRICT} ({@link #DEFAULT_PROFILE}). + * When {@code security_defaults} is absent, or present without a {@code profile}, the + * gateway-wide effective profile is {@code STRICT} — the fail-closed choice.
  2. + *
  3. {@link #NONE} contributes no limits policy. {@code none} is a statement + * about validation, never about limits: a {@code none} route still needs a + * concrete {@link SecurityConfiguration} so the retained {@code max_body_bytes} guard has a + * {@code maxBodySize} to enforce. {@link #preset()} therefore refuses to answer for + * {@code NONE}; callers resolve the limits policy through + * {@link #limitsProfile(SecurityProfile, SecurityProfile)}, which walks to the nearest + * non-{@code none} profile in the fallback chain and lands on {@code STRICT} when the whole + * chain is {@code none}.
  4. + *
+ *

+ * {@code none} is a partial disable, not "the inbound filter is off": it turns off + * exactly the url-parameter name/value validation and the per-route pipeline re-run. The pre-route + * floor, {@code max_body_bytes} and {@code allowed_paths} all keep running under it. See ADR-0023. + *

+ * Immutable and thread-safe. + * + * @author API Sheriff Team + * @since 1.0 + */ +public enum SecurityProfile { + + /** The tightest inbound posture, backed by {@link SecurityConfiguration#strict()}. */ + STRICT, + + /** The relaxed inbound posture, backed by {@link SecurityConfiguration#lenient()}. */ + LENIENT, + + /** + * The partial disable: url-parameter name/value validation and the per-route pipeline re-run + * are skipped, while the pre-route floor, the body cap and the path allowlist keep running. + * Refused at boot on effectively-authenticated and BFF routes. + */ + NONE; + + /** + * The profile an omitted {@code security_defaults} block — or a {@code security_defaults} block + * without a {@code profile} — resolves to. Fail-closed by construction. + */ + public static final SecurityProfile DEFAULT_PROFILE = STRICT; + + /** + * Parses a configured {@code profile} scalar case-insensitively. + * + * @param value the raw configured value, may be {@code null} + * @return the matching profile, or {@link Optional#empty()} when the value is {@code null} or + * names no mode of this set (the dropped {@code default} preset included) + */ + public static Optional parse(@Nullable String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.strip().toUpperCase(Locale.ROOT); + for (SecurityProfile profile : values()) { + if (profile.name().equals(normalized)) { + return Optional.of(profile); + } + } + return Optional.empty(); + } + + /** + * Resolves the profile whose preset supplies the limits policy for a route, by walking + * to the nearest non-{@link #NONE} entry of the {@code effective → fallback} chain and landing + * on {@link #STRICT} when every level is {@code NONE}. This is what keeps a {@code none} route's + * body cap and collection limits concrete and enforceable. + * + * @param effective the route's (or the gateway's) effective profile + * @param fallback the next profile down the chain — the gateway-wide profile for a route + * @return the profile to take the limits policy from; never {@link #NONE} + */ + public static SecurityProfile limitsProfile(SecurityProfile effective, SecurityProfile fallback) { + if (effective != NONE) { + return effective; + } + return fallback != NONE ? fallback : STRICT; + } + + /** + * @return the backing cui-http preset for this mode + * @throws IllegalStateException when called on {@link #NONE}, which contributes no limits + * policy — resolve one through + * {@link #limitsProfile(SecurityProfile, SecurityProfile)} first + */ + public SecurityConfiguration preset() { + return switch (this) { + case STRICT -> SecurityConfiguration.strict(); + case LENIENT -> SecurityConfiguration.lenient(); + case NONE -> throw new IllegalStateException( + "profile 'none' has no cui-http preset; resolve the limits profile via limitsProfile(..)"); + }; + } + + /** + * @return {@code true} when the skippable half of the per-route checks — the url-parameter + * name/value validation and the pipeline re-run — is active; {@code false} only for + * {@link #NONE} + */ + public boolean skippableValidationEnabled() { + return this != NONE; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index c12039b6..e9f79077 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java @@ -58,7 +58,9 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedAsset; import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; +import de.cuioss.sheriff.gateway.config.model.SecurityDefaultsConfig; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.config.model.TlsConfig; import de.cuioss.sheriff.gateway.events.EventCategory; import de.cuioss.sheriff.gateway.events.EventType; @@ -110,7 +112,9 @@ * Boot-time assembly. The constructor compiles the frozen {@link RouteTable} into * immutable {@link RouteRuntime} instances via the {@link RouteRuntimeAssembler} (deduplicating the * shared Vert.x clients and SmallRye guards), builds every stage once with the shared - * {@link SecurityEventCounter}, the default {@link SecurityConfiguration}, the boot-wired + * {@link SecurityEventCounter}, the gateway-wide {@link SecurityConfiguration} seeded from the + * resolved {@code security_defaults} {@link SecurityProfile} (an omitted block resolving to + * {@link SecurityProfile#DEFAULT_PROFILE}), the boot-wired * {@link ForwardedHeaderResolver} + {@link TcpPeerGate} (from the global {@code forwarded} block), * and the shared {@link GatewayEventCounter}. That same shared {@link SecurityEventCounter} is bound * to {@link SheriffMetrics} here so its per-{@code UrlSecurityFailureType} counts surface as the @@ -274,7 +278,17 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, this.webSocketRelayAdmission = new Semaphore(hardening.webSocketRelayCap()); SecurityEventCounter securityEventCounter = new SecurityEventCounter(); - SecurityConfiguration defaultConfiguration = SecurityConfiguration.defaults(); + // The gateway-wide baseline is seeded from the RESOLVED security_defaults profile rather + // than from a hard-coded SecurityConfiguration.defaults(). This single instance feeds + // BasicChecksStage, ThoroughChecksStage's skip-if-equal baseline, + // ForwardedResolverConfig.securityConfig and defaultMaxBodySize — so seeding it here is + // what makes an omitted security_defaults block genuinely mean 'strict' for every route. + SecurityProfile globalProfile = gatewayConfig.securityDefaults() + .flatMap(SecurityDefaultsConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(SecurityProfile.DEFAULT_PROFILE); + SecurityConfiguration defaultConfiguration = + SecurityProfile.limitsProfile(globalProfile, globalProfile).preset(); this.defaultMaxBodySize = defaultConfiguration.maxBodySize(); this.gatewayEventCounter = new GatewayEventCounter(); this.upstreamFailureMapper = new UpstreamFailureMapper(gatewayEventCounter); @@ -291,7 +305,7 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, RouteRuntimeAssembler assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry()); this.routes = assembler.assemble(routeTable, - GatewayEdgeRoute::securityConfigurationFor, + filter -> securityPostureFor(filter, globalProfile), target -> clientFor(vertx, target), this::guardFor, GatewayEdgeRoute::assetSourceFor); @@ -1245,12 +1259,43 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT } /** - * Maps a route's {@code security_filter} block to a cui-http {@link SecurityConfiguration}, - * seeding the safe builder defaults and overriding only the limits the route declared, so an - * undeclared dimension never falls below the gateway baseline. + * Resolves a route's inbound-filter posture: the effective {@link SecurityProfile} after the + * {@code security_filter → security_defaults} fallback, plus the cui-http + * {@link SecurityConfiguration} carrying its limits. + *

+ * The limits are seeded from the preset of the nearest non-{@code none} profile in the + * chain (see {@link SecurityProfile#limitsProfile}), never from bare builder defaults, and only + * the dimensions the route actually declared are overridden on top — so an undeclared dimension + * lands exactly on the resolved preset rather than below it. A {@code none} route therefore + * still carries a concrete, enforceable {@code maxBodySize}. + *

+ * Invoked for every route, including one that declares no {@code security_filter} block at all, + * which is what lets a gateway-wide {@code profile} govern a block-less route. + *

+ * Package-private rather than private so the posture resolution — the fallback chain and the + * preset-seeded limit overrides — is asserted directly by {@code GatewayEdgeRouteTest} instead + * of only through a booted edge, whose assembled routes are not observable. + */ + static RouteRuntimeAssembler.SecurityPosture securityPostureFor( + Optional filter, SecurityProfile globalProfile) { + SecurityProfile effective = filter.flatMap(SecurityFilterConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(globalProfile); + SecurityConfiguration preset = SecurityProfile.limitsProfile(effective, globalProfile).preset(); + return new RouteRuntimeAssembler.SecurityPosture(effective, + filter.map(declared -> applyDeclaredLimits(preset, declared)).orElse(preset)); + } + + /** + * Applies a route's declared {@code security_filter} limits on top of the resolved preset. The + * builder is seeded component-by-component from {@code preset} because the cui-http + * {@link SecurityConfiguration} exposes no preset-seeded builder — {@link SecurityConfiguration#builder()} + * starts from {@code defaults()}, so seeding explicitly is the only way an undeclared dimension + * keeps the preset's value instead of silently reverting to the default policy. */ - private static SecurityConfiguration securityConfigurationFor(SecurityFilterConfig filter) { - SecurityConfigurationBuilder builder = SecurityConfiguration.builder(); + private static SecurityConfiguration applyDeclaredLimits(SecurityConfiguration preset, + SecurityFilterConfig filter) { + SecurityConfigurationBuilder builder = builderSeededFrom(preset); filter.maxBodyBytes().ifPresent(value -> builder.maxBodySize(value.longValue())); filter.maxQueryParams().ifPresent(builder::maxParameterCount); filter.maxHeaderCount().ifPresent(builder::maxHeaderCount); @@ -1268,6 +1313,40 @@ private static SecurityConfiguration securityConfigurationFor(SecurityFilterConf return builder.build(); } + /** + * Returns a {@link SecurityConfigurationBuilder} carrying every component of {@code preset}, so + * a caller overriding one dimension leaves the other twenty-three on the preset's values. Copies + * the record's full component set deliberately: an omitted component would silently fall back to + * the {@code defaults()} policy the builder starts from. + */ + private static SecurityConfigurationBuilder builderSeededFrom(SecurityConfiguration preset) { + return SecurityConfiguration.builder() + .maxPathLength(preset.maxPathLength()) + .allowDoubleEncoding(preset.allowDoubleEncoding()) + .maxParameterNameLength(preset.maxParameterNameLength()) + .maxParameterValueLength(preset.maxParameterValueLength()) + .maxHeaderNameLength(preset.maxHeaderNameLength()) + .maxHeaderValueLength(preset.maxHeaderValueLength()) + .maxCookieNameLength(preset.maxCookieNameLength()) + .maxCookieValueLength(preset.maxCookieValueLength()) + .maxBodySize(preset.maxBodySize()) + .allowNullBytes(preset.allowNullBytes()) + .allowControlCharacters(preset.allowControlCharacters()) + .allowExtendedAscii(preset.allowExtendedAscii()) + .normalizeUnicode(preset.normalizeUnicode()) + .caseSensitiveComparison(preset.caseSensitiveComparison()) + .failOnSuspiciousPatterns(preset.failOnSuspiciousPatterns()) + .requireSecureCookies(preset.requireSecureCookies()) + .requireHttpOnlyCookies(preset.requireHttpOnlyCookies()) + .maxParameterCount(preset.maxParameterCount()) + .maxHeaderCount(preset.maxHeaderCount()) + .maxCookieCount(preset.maxCookieCount()) + .allowedHeaderNames(preset.allowedHeaderNames()) + .blockedHeaderNames(preset.blockedHeaderNames()) + .allowedContentTypes(preset.allowedContentTypes()) + .blockedContentTypes(preset.blockedContentTypes()); + } + /** * Builds the per-shape SmallRye Fault-Tolerance guard: a circuit breaker plus an upstream * timeout, with retry added only for a route that enables it. Gateway rejections diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java index 50efb2a4..aa178ff6 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java @@ -34,6 +34,7 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.events.EventType; import de.cuioss.sheriff.gateway.events.GatewayException; import de.cuioss.sheriff.gateway.routing.ProtocolProcessor; @@ -49,7 +50,11 @@ * {@link RouteRuntime} instances, deduplicating the heavy collaborators so shared shapes * reuse one object rather than copying it: *