
001/*- 002 * #%L 003 * HAPI FHIR - Server Framework 004 * %% 005 * Copyright (C) 2014 - 2025 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.rest.server.interceptor; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.interceptor.api.Hook; 024import ca.uhn.fhir.interceptor.api.Interceptor; 025import ca.uhn.fhir.interceptor.api.Pointcut; 026import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 027import ca.uhn.fhir.rest.server.method.BaseMethodBinding; 028import ca.uhn.fhir.rest.server.method.OperationMethodBinding; 029import jakarta.annotation.Nonnull; 030import org.apache.commons.lang3.Validate; 031import org.slf4j.Logger; 032import org.slf4j.LoggerFactory; 033 034import java.util.Collections; 035import java.util.HashSet; 036import java.util.Set; 037import java.util.TreeSet; 038 039import static org.apache.commons.lang3.StringUtils.isBlank; 040 041/** 042 * This interceptor can be used to selectively block specific interactions/operations from 043 * the server's capabilities. This interceptor must be configured and registered to a 044 * {@link ca.uhn.fhir.rest.server.RestfulServer} prior to any resource provider 045 * classes being registered to it. This interceptor will then examine any 046 * provider classes being registered and may choose to discard some or all 047 * of the method bindings on each provider. 048 * <p> 049 * For example, if this interceptor is configured to block resource creation, then 050 * when a resource provider is registered that has both a 051 * {@link ca.uhn.fhir.rest.annotation.Read @Read} method and a 052 * {@link ca.uhn.fhir.rest.annotation.Create @Create} method, the 053 * create method will be ignored and not bound. 054 * </p> 055 * <p> 056 * Note: This interceptor is not a security interceptor! It can be used to remove 057 * writes capabilities from a FHIR endpoint (for example) but it does not guarantee 058 * that writes won't be possible. Security rules should be enforced using 059 * {@link ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor} or 060 * a similar strategy. However, this interceptor can be useful in order to 061 * clarify the intent of an endpoint to the outside world. Of particular note, 062 * even if a create method has been blocked from binding by this interceptor, 063 * it may still be possible to create resources via a FHIR transaction unless 064 * proper security has been implemented. 065 * </p> 066 * <p> 067 * Use {@link Builder new Builder()} to create a new instance of this class. 068 * </p> 069 * 070 * @see Builder#addAllowedSpec(String) to add allowed interactions 071 * @since 6.2.0 072 */ 073@Interceptor 074public class InteractionBlockingInterceptor { 075 076 public static final Set<RestOperationTypeEnum> ALLOWED_OP_TYPES; 077 private static final Logger ourLog = LoggerFactory.getLogger(InteractionBlockingInterceptor.class); 078 079 static { 080 Set<RestOperationTypeEnum> allowedOpTypes = new TreeSet<>(); 081 allowedOpTypes.add(RestOperationTypeEnum.META); 082 allowedOpTypes.add(RestOperationTypeEnum.META_ADD); 083 allowedOpTypes.add(RestOperationTypeEnum.META_DELETE); 084 allowedOpTypes.add(RestOperationTypeEnum.PATCH); 085 allowedOpTypes.add(RestOperationTypeEnum.READ); 086 allowedOpTypes.add(RestOperationTypeEnum.CREATE); 087 allowedOpTypes.add(RestOperationTypeEnum.UPDATE); 088 allowedOpTypes.add(RestOperationTypeEnum.DELETE); 089 allowedOpTypes.add(RestOperationTypeEnum.BATCH); 090 allowedOpTypes.add(RestOperationTypeEnum.TRANSACTION); 091 allowedOpTypes.add(RestOperationTypeEnum.VALIDATE); 092 allowedOpTypes.add(RestOperationTypeEnum.SEARCH_TYPE); 093 allowedOpTypes.add(RestOperationTypeEnum.HISTORY_TYPE); 094 allowedOpTypes.add(RestOperationTypeEnum.HISTORY_INSTANCE); 095 allowedOpTypes.add(RestOperationTypeEnum.HISTORY_SYSTEM); 096 ALLOWED_OP_TYPES = Collections.unmodifiableSet(allowedOpTypes); 097 } 098 099 private final Set<String> myAllowedKeys; 100 101 private final FhirContext myFhirContext; 102 103 /** 104 * Constructor 105 */ 106 private InteractionBlockingInterceptor(@Nonnull Builder theBuilder, FhirContext theFhirContext) { 107 myAllowedKeys = theBuilder.myAllowedKeys; 108 myFhirContext = theFhirContext; 109 } 110 111 @Hook(Pointcut.SERVER_PROVIDER_METHOD_BOUND) 112 public BaseMethodBinding bindMethod(BaseMethodBinding theMethodBinding) { 113 114 boolean allowed = true; 115 String resourceName = theMethodBinding.getResourceName(); 116 RestOperationTypeEnum restOperationType = theMethodBinding.getRestOperationType(); 117 switch (restOperationType) { 118 case EXTENDED_OPERATION_SERVER: 119 case EXTENDED_OPERATION_TYPE: 120 case EXTENDED_OPERATION_INSTANCE: { 121 OperationMethodBinding operationMethodBinding = (OperationMethodBinding) theMethodBinding; 122 if (!myAllowedKeys.isEmpty()) { 123 if (!myAllowedKeys.contains(operationMethodBinding.getName())) { 124 allowed = false; 125 } 126 } 127 break; 128 } 129 case GET_PAGE: 130 /* 131 * PageProvider is registered without type; 132 * we need to verify each page individually 133 */ 134 for (String resourceType : myFhirContext.getResourceTypes()) { 135 String key = toKey(resourceType, RestOperationTypeEnum.SEARCH_TYPE); 136 /* 137 * We allow PageProvider to be registered if *any* 138 * resource:search is registered. 139 * 140 * This won't cause any security leaks because 141 * in order to get a second page, you must first get the first 142 * page (ie, resource:search must be allowed). 143 * 144 * So any _getpages we receive must necessarily also be allowed. 145 */ 146 allowed = myAllowedKeys.contains(key); 147 if (allowed) { 148 break; 149 } 150 } 151 break; 152 default: { 153 if (restOperationType == RestOperationTypeEnum.VREAD) { 154 restOperationType = RestOperationTypeEnum.READ; 155 } 156 String key = toKey(resourceName, restOperationType); 157 if (!myAllowedKeys.isEmpty()) { 158 if (!myAllowedKeys.contains(key)) { 159 allowed = false; 160 } 161 } 162 break; 163 } 164 } 165 166 if (!allowed) { 167 ourLog.info( 168 "Skipping method binding for {}:{} provided by {}", 169 resourceName, 170 restOperationType, 171 theMethodBinding.getMethod()); 172 return null; 173 } 174 175 return theMethodBinding; 176 } 177 178 private static String toKey(String theResourceType, RestOperationTypeEnum theRestOperationTypeEnum) { 179 if (isBlank(theResourceType)) { 180 return theRestOperationTypeEnum.getCode(); 181 } 182 return theResourceType + ":" + theRestOperationTypeEnum.getCode(); 183 } 184 185 public static class Builder { 186 187 private final Set<String> myAllowedKeys = new HashSet<>(); 188 private final FhirContext myCtx; 189 190 /** 191 * Constructor 192 */ 193 public Builder(@Nonnull FhirContext theCtx) { 194 Validate.notNull(theCtx, "theCtx must not be null"); 195 myCtx = theCtx; 196 } 197 198 /** 199 * Adds an interaction or operation that will be permitted. Allowable formats 200 * are: 201 * <ul> 202 * <li> 203 * <b>[resourceType]:[interaction]</b> - Use this form to allow type- and instance-level interactions, such as 204 * <code>create</code>, <code>read</code>, and <code>patch</code>. For example, the spec <code>Patient:create</code> 205 * allows the Patient-level create operation (i.e. <code>POST /Patient</code>). 206 * </li> 207 * <li> 208 * <b>$[operation-name]</b> - Use this form to allow operations (at any level) by name. For example, the spec 209 * <code>$diff</code> permits the <a href="https://hapifhir.io/hapi-fhir/docs/server_jpa/diff.html">Diff Operation</a> 210 * to be applied at both the server- and instance-level. 211 * </li> 212 * </ul> 213 * <p> 214 * Note that the spec does not differentiate between the <code>read</code> and <code>vread</code> interactions. If one 215 * is permitted the other will also be permitted. 216 * </p> 217 * 218 * @return 219 */ 220 public Builder addAllowedSpec(String theSpec) { 221 Validate.notBlank(theSpec, "theSpec must not be null or blank"); 222 223 if (theSpec.startsWith("$")) { 224 addAllowedOperation(theSpec); 225 return this; 226 } 227 228 int colonIdx = theSpec.indexOf(':'); 229 Validate.isTrue(colonIdx > 0, "Invalid interaction allowed spec: %s", theSpec); 230 231 String resourceName = theSpec.substring(0, colonIdx); 232 String interactionName = theSpec.substring(colonIdx + 1); 233 if (interactionName.equals("search")) { 234 interactionName = "search-type"; 235 validateInteraction(interactionName, theSpec, resourceName); 236 } else if (interactionName.equals("history")) { 237 validateInteraction("history-instance", theSpec, resourceName); 238 validateInteraction("history-type", theSpec, resourceName); 239 } else { 240 validateInteraction(interactionName, theSpec, resourceName); 241 } 242 return this; 243 } 244 245 private void validateInteraction(String theInteractionName, String theSpec, String theResourceName) { 246 RestOperationTypeEnum interaction = RestOperationTypeEnum.forCode(theInteractionName); 247 Validate.notNull(interaction, "Unknown interaction %s in spec %s", theInteractionName, theSpec); 248 addAllowedInteraction(theResourceName, interaction); 249 } 250 251 /** 252 * Adds an interaction that will be permitted. 253 */ 254 private void addAllowedInteraction(String theResourceType, RestOperationTypeEnum theInteractionType) { 255 Validate.notBlank(theResourceType, "theResourceType must not be null or blank"); 256 Validate.notNull(theInteractionType, "theInteractionType must not be null"); 257 Validate.isTrue( 258 ALLOWED_OP_TYPES.contains(theInteractionType), 259 "Operation type %s can not be used as an allowable rule", 260 theInteractionType); 261 Validate.isTrue(myCtx.getResourceType(theResourceType) != null, "Unknown resource type: %s"); 262 String key = toKey(theResourceType, theInteractionType); 263 myAllowedKeys.add(key); 264 } 265 266 private void addAllowedOperation(String theOperationName) { 267 Validate.notBlank(theOperationName, "theOperationName must not be null or blank"); 268 Validate.isTrue(theOperationName.startsWith("$"), "Invalid operation name: %s", theOperationName); 269 myAllowedKeys.add(theOperationName); 270 } 271 272 public InteractionBlockingInterceptor build() { 273 return new InteractionBlockingInterceptor(this, myCtx); 274 } 275 } 276}