Skip to content

Commit

Permalink
feat: supports Matches and NotMatches operators in security conditions.
Browse files Browse the repository at this point in the history
  • Loading branch information
outofcoffee committed Jun 25, 2023
1 parent b5a8d50 commit 47605cd
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 71 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2016-2021.
* Copyright (c) 2016-2023.
*
* This file is part of Imposter.
*
Expand Down Expand Up @@ -46,5 +46,5 @@ package io.gatehill.imposter.plugin.config.security
* @author Pete Cornish
*/
enum class SecurityMatchOperator {
EqualTo, NotEqualTo, Regex
EqualTo, NotEqualTo, Matches, NotMatches
}
12 changes: 9 additions & 3 deletions core/api/src/main/java/io/gatehill/imposter/util/StringUtil.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2016-2021.
* Copyright (c) 2016-2023.
*
* This file is part of Imposter.
*
Expand Down Expand Up @@ -42,7 +42,7 @@
*/
package io.gatehill.imposter.util

import java.util.*
import java.util.Objects

/**
* @author Pete Cornish
Expand All @@ -63,4 +63,10 @@ object StringUtil {
Objects.isNull(b)
}
}
}

/**
* Checks if the actual value matches the given regular expression.
*/
fun safeRegexMatch(actualValue: String?, expression: String?) =
expression?.toRegex()?.matches(actualValue ?: "") ?: false
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,28 @@ package io.gatehill.imposter.service.security
import io.gatehill.imposter.http.HttpExchange
import io.gatehill.imposter.lifecycle.SecurityLifecycleHooks
import io.gatehill.imposter.plugin.config.PluginConfig
import io.gatehill.imposter.plugin.config.security.*
import io.gatehill.imposter.plugin.config.security.ConditionalNameValuePair
import io.gatehill.imposter.plugin.config.security.SecurityCondition
import io.gatehill.imposter.plugin.config.security.SecurityConfig
import io.gatehill.imposter.plugin.config.security.SecurityConfigHolder
import io.gatehill.imposter.plugin.config.security.SecurityEffect
import io.gatehill.imposter.plugin.config.security.SecurityMatchOperator
import io.gatehill.imposter.service.SecurityService
import io.gatehill.imposter.util.CollectionUtil.convertKeysToLowerCase
import io.gatehill.imposter.util.HttpUtil
import io.gatehill.imposter.util.LogUtil
import io.gatehill.imposter.util.StringUtil.safeEquals
import io.gatehill.imposter.util.StringUtil.safeRegexMatch
import org.apache.logging.log4j.LogManager
import java.util.*
import java.util.Locale
import javax.inject.Inject

/**
* @author Pete Cornish
*/
class SecurityServiceImpl @Inject constructor(
securityLifecycle: SecurityLifecycleHooks,
securityListener: SecurityLifecycleListenerImpl
securityListener: SecurityLifecycleListenerImpl,
) : SecurityService {

init {
Expand Down Expand Up @@ -176,20 +182,27 @@ class SecurityServiceImpl @Inject constructor(
conditionMap: Map<String, ConditionalNameValuePair>,
requestMap: Map<String, String>,
conditionEffect: SecurityEffect,
caseSensitiveKeyMatch: Boolean
caseSensitiveKeyMatch: Boolean,
): List<SecurityEffect> {
val comparisonMap = if (caseSensitiveKeyMatch) requestMap else convertKeysToLowerCase(requestMap)
return conditionMap.values.map { conditionValue: ConditionalNameValuePair ->
val requestConditionValue = comparisonMap[if (caseSensitiveKeyMatch) conditionValue.name else conditionValue.name.lowercase(Locale.getDefault())];
val valueMatch = safeEquals(
requestConditionValue,
conditionValue.value
)
val key = if (caseSensitiveKeyMatch) conditionValue.name else conditionValue.name.lowercase(Locale.getDefault())
val requestConditionValue = comparisonMap[key]

val regexMatch : Boolean = conditionValue.value?.toRegex()?.matches(requestConditionValue.toString()) ?: false;
val matched = conditionValue.operator === SecurityMatchOperator.EqualTo && valueMatch ||
conditionValue.operator === SecurityMatchOperator.NotEqualTo && !valueMatch
|| conditionValue.operator === SecurityMatchOperator.Regex && regexMatch
val matched: Boolean = when (conditionValue.operator) {
SecurityMatchOperator.EqualTo -> {
safeEquals(requestConditionValue, conditionValue.value)
}
SecurityMatchOperator.NotEqualTo -> {
!safeEquals(requestConditionValue, conditionValue.value)
}
SecurityMatchOperator.Matches -> {
safeRegexMatch(requestConditionValue, conditionValue.value)
}
SecurityMatchOperator.NotMatches -> {
!safeRegexMatch(requestConditionValue, conditionValue.value)
}
}

val finalEffect: SecurityEffect = if (matched) {
conditionEffect
Expand Down Expand Up @@ -239,7 +252,7 @@ class SecurityServiceImpl @Inject constructor(
private fun describeConditionPart(
description: StringBuilder,
part: Map<String, ConditionalNameValuePair>,
partType: String
partType: String,
) {
if (part.isNotEmpty()) {
if (description.isNotEmpty()) {
Expand Down
36 changes: 29 additions & 7 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ security:
Authentication configuration uses the following terms:
| Term | Meaning | Examples |
|-----------|-----------------------------------------------------------------------------|------------------------------------|
| Condition | A property of the request, such as the presence of a specific header value. | `Authorization` header value `foo` |
| Operator | How the condition is matched. | `EqualTo`, `NotEqualTo` |
| Effect | The impact of the condition on the request, such as it being denied. | `Permit`, `Deny` |
| Term | Meaning | Examples |
|-----------|-----------------------------------------------------------------------------|--------------------------------------------------|
| Condition | A property of the request, such as the presence of a specific header value. | `Authorization` header value `foo` |
| Operator | How the condition is matched. | `EqualTo`, `NotEqualTo`, `Matches`, `NotMatches` |
| Effect | The impact of the condition on the request, such as it being denied. | `Permit`, `Deny` |

The first important concept is the _Default Effect_. This is the effect that applies to all requests in the absence of a more specific condition. It is good practice to adhere the principle of least privilege. You can achieve this by setting the default effect to `Deny`, and then adding specific conditions that permit access.

Expand Down Expand Up @@ -118,6 +118,18 @@ conditions:
value: opensesame
operator: EqualTo
- effect: Permit
requestHeaders:
Authorization:
value: Bearer .*
operator: Matches
- effect: Deny
requestHeaders:
Authorization:
value: Bearer sometoken
operator: NotMatches
- effect: Deny
queryParams:
apiKey: someblockedkey
Expand Down Expand Up @@ -145,10 +157,20 @@ If you want to control the logical operator you can use the extended form as fol
operator: NotEqualTo
```

By default, conditions are matched using the `EqualTo` operator.

Here, the value of the `example` query parameter is specified as a child property named `value`. The `operator` is also specified in this form, such as `EqualTo` or `NotEqualTo`.

> **Note**
> If no `operator` is specified, then `EqualTo` is used.

The following operators are supported:

| Operator | Description |
|---------------|-----------------------------------------------------------------------------------------------|
| `EqualTo` | Checks if the condition equals the `value`. |
| `NotEqualTo` | Checks if the condition does not equal the `value`. |
| `Matches` | Checks if the condition matches the regular expression specified in the `value` field. |
| `NotMatches` | Checks if the condition does not match the regular expression specified in the `value` field. |

### Combining conditions

The presence of more than one header in a condition requires all header values match in order for the condition to be satisfied.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2023-2023.
*
* This file is part of Imposter.
*
* "Commons Clause" License Condition v1.0
*
* The Software is provided to you by the Licensor under the License, as
* defined below, subject to the following condition.
*
* Without limiting other conditions in the License, the grant of rights
* under the License will not include, and the License does not grant to
* you, the right to Sell the Software.
*
* For purposes of the foregoing, "Sell" means practicing any or all of
* the rights granted to you under the License to provide to third parties,
* for a fee or other consideration (including without limitation fees for
* hosting or consulting/support services related to the Software), a
* product or service whose value derives, entirely or substantially, from
* the functionality of the Software. Any license notice or attribution
* required by the License must also include this Commons Clause License
* Condition notice.
*
* Software: Imposter
*
* License: GNU Lesser General Public License version 3
*
* Licensor: Peter Cornish
*
* Imposter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Imposter is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Imposter. If not, see <https://www.gnu.org/licenses/>.
*/
package io.gatehill.imposter.server

import io.gatehill.imposter.plugin.test.TestPluginImpl
import io.gatehill.imposter.util.HttpUtil
import io.restassured.RestAssured
import io.vertx.ext.unit.TestContext
import io.vertx.ext.unit.junit.VertxUnitRunner
import org.apache.commons.lang3.RandomStringUtils
import org.hamcrest.Matchers
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* Tests for security configuration using regular expressions.
*/
@RunWith(VertxUnitRunner::class)
class SecurityConfigRegexTest : BaseVerticleTest() {
override val pluginClass = TestPluginImpl::class.java

@Before
@Throws(Exception::class)
override fun setUp(testContext: TestContext) {
super.setUp(testContext)
RestAssured.baseURI = "http://$host:$listenPort"
}

override val testConfigDirs = listOf(
"/security-config-regex"
)

/**
* Permit - request is permitted via regex.
*/
@Test
fun testRegexMatchesRequestPermitted() {
RestAssured.given().`when`()
.header("Authorization", "Bearer " + RandomStringUtils.random(50, "ABCDEFGHIJKLMNOPRSTUVXYZabcdefghijklmnoprstuvxyz1234567890"))
.get("/match")
.then()
.statusCode(Matchers.equalTo(HttpUtil.HTTP_OK))
}

/**
* Deny - request is denied because of not matching regex
*/
@Test
fun testRegexMatchesRequestDeny() {
RestAssured.given().`when`()
.header("Authorization", "Token ")
.get("/match")
.then()
.statusCode(Matchers.equalTo(HttpUtil.HTTP_UNAUTHORIZED))
}

/**
* Permit - request is denied because of matching regex
*/
@Test
fun testRegexNotMatchesRequestPermit() {
RestAssured.given().`when`()
.header("Authorization", "Bearer magic-token")
.get("/does-not-match")
.then()
.statusCode(Matchers.equalTo(HttpUtil.HTTP_OK))
}

/**
* Deny - request is denied because of matching regex
*/
@Test
fun testRegexNotMatchesRequestDeny() {
RestAssured.given().`when`()
.header("Authorization", "Bearer bad-token")
.get("/does-not-match")
.then()
.statusCode(Matchers.equalTo(HttpUtil.HTTP_UNAUTHORIZED))
}
}
Loading

0 comments on commit 47605cd

Please sign in to comment.