diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/base/ExistenceLeakageApplication.kt similarity index 84% rename from core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageApplication.kt rename to core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/base/ExistenceLeakageApplication.kt index 21ab8a11e6..b5627e31c6 100644 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageApplication.kt +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/base/ExistenceLeakageApplication.kt @@ -1,11 +1,15 @@ -package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage +package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.base import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* - +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController @SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) @RequestMapping(path = ["/api/resources"]) diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/parentauth/ExistenceLeakageParentApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/parentauth/ExistenceLeakageParentApplication.kt new file mode 100644 index 0000000000..2627c926b3 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/parentauth/ExistenceLeakageParentApplication.kt @@ -0,0 +1,135 @@ +package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.parentauth + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/api/parents") +open class ExistenceLeakageParentApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(ExistenceLeakageParentApplication::class.java, *args) + } + + private val parents = mutableMapOf() + private val children = mutableMapOf, String>() + + fun reset() { + parents.clear() + children.clear() + } + } + + private fun checkAuth(auth: String?) = + auth != null && (auth == "FOO" || auth == "BAR") + + + @PutMapping("/{pid}") + open fun createParent( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int + ): ResponseEntity { + + if (!checkAuth(auth)) return ResponseEntity.status(401).build() + + if (!parents.containsKey(pid)) { + parents[pid] = auth!! + return ResponseEntity.status(201).build() + } + + return if (parents[pid] != auth) + ResponseEntity.status(403).build() + else + ResponseEntity.status(204).build() + } + + @GetMapping("/{pid}") + open fun getParent( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int + ): ResponseEntity { + + if (!checkAuth(auth)) return ResponseEntity.status(401).build() + + if (!parents.containsKey(pid)) { + return ResponseEntity.status(403).build() + } + + if (parents[pid] != auth) { + return ResponseEntity.status(403).build() + } + + return ResponseEntity.ok(parents[pid]) + } + + @PutMapping("/{pid}/children/{cid}") + open fun createChild( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int, + @PathVariable cid: Int + ): ResponseEntity { + + if (!checkAuth(auth)) return ResponseEntity.status(401).build() + + if (!parents.containsKey(pid)) { + return ResponseEntity.status(404).build() + } + + if (parents[pid] != auth) { + return ResponseEntity.status(403).build() + } + + val key = Pair(pid, cid) + if (!children.containsKey(key)) { + children[key] = auth!! + return ResponseEntity.status(201).build() + } + + return ResponseEntity.status(204).build() + } + + @GetMapping("/{pid}/children/{cid}") + open fun getChild( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int, + @PathVariable cid: Int + ): ResponseEntity { + + // Authentication is required + if (!checkAuth(auth)) { + return ResponseEntity.status(401).build() + } + + // If the parent resource does not exist, returning 403 is correct for everyone + if (!parents.containsKey(pid)) { + return ResponseEntity.status(404).build() + } + + // If the parent exists but is not owned by the caller, + // we must not leak whether the child exists or not + if (parents[pid] != auth) { + return ResponseEntity.status(403).build() + } + + val key = Pair(pid, cid) + + // At this point, the caller owns the parent. + // If the child does not exist, returning 404 is legitimate + if (!children.containsKey(key)) { + return ResponseEntity.status(404).build() + } + + // Optional safety check: child ownership + if (children[key] != auth) { + return ResponseEntity.status(403).build() + } + + return ResponseEntity.ok(children[key]) + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/parentnoexistence/ExistenceLeakageParentNoExistenceApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/parentnoexistence/ExistenceLeakageParentNoExistenceApplication.kt new file mode 100644 index 0000000000..e179dac50a --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/parentnoexistence/ExistenceLeakageParentNoExistenceApplication.kt @@ -0,0 +1,136 @@ +package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.parentnoexistence + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/api/parents") +open class ExistenceLeakageParentNoExistenceApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(ExistenceLeakageParentNoExistenceApplication::class.java, *args) + } + + private val parents = mutableMapOf() + private val children = mutableMapOf, String>() + + fun reset() { + parents.clear() + children.clear() + } + } + + private fun checkAuth(auth: String?) = + auth != null && (auth == "FOO" || auth == "BAR") + + + @PutMapping("/{pid}") + open fun createParent( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int + ): ResponseEntity { + + if (!checkAuth(auth)) return ResponseEntity.status(401).build() + + if (!parents.containsKey(pid)) { + parents[pid] = auth!! + return ResponseEntity.status(201).build() + } + + return if (parents[pid] != auth) + ResponseEntity.status(403).build() + else + ResponseEntity.status(204).build() + } + + @GetMapping("/{pid}") + open fun getParent( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int + ): ResponseEntity { + + if (!checkAuth(auth)) return ResponseEntity.status(401).build() + + if (!parents.containsKey(pid)) { + return ResponseEntity.status(403).build() + } + + if (parents[pid] != auth) { + return ResponseEntity.status(403).build() + } + + return ResponseEntity.ok(parents[pid]) + } + + @PutMapping("/{pid}/children/{cid}") + open fun createChild( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int, + @PathVariable cid: Int + ): ResponseEntity { + + if (!checkAuth(auth)) return ResponseEntity.status(401).build() + + if (!parents.containsKey(pid)) { + return ResponseEntity.status(404).build() + } + + if (parents[pid] != auth) { + return ResponseEntity.status(403).build() + } + + val key = Pair(pid, cid) + if (!children.containsKey(key)) { + children[key] = auth!! + return ResponseEntity.status(201).build() + } + + return ResponseEntity.status(204).build() + } + + @GetMapping("/{pid}/children/{cid}") + open fun getChild( + @RequestHeader("Authorization") auth: String?, + @PathVariable pid: Int, + @PathVariable cid: Int + ): ResponseEntity { + + // Authentication is required + if (!checkAuth(auth)) { + return ResponseEntity.status(401).build() + } + + // If the parent resource does not exist, returning 403 is correct for everyone + if (!parents.containsKey(pid)) { + return ResponseEntity.status(403).build() + } + + // If the parent exists but is not owned by the caller, + // we must not leak whether the child exists or not + if (parents[pid] != auth) { + return ResponseEntity.status(403).build() + } + + val key = Pair(pid, cid) + + // At this point, the caller owns the parent. + // If the child does not exist, returning 404 is legitimate + if (!children.containsKey(key)) { + return ResponseEntity.status(404).build() + } + + // Optional safety check: child ownership + if (children[key] != auth) { + return ResponseEntity.status(403).build() + } + + return ResponseEntity.ok(children[key]) + } + +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageController.kt index 213e74f2fd..c0e24291c9 100644 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageController.kt +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageController.kt @@ -1,6 +1,7 @@ package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.base.ExistenceLeakageApplication import org.evomaster.client.java.controller.AuthUtils import org.evomaster.client.java.controller.api.dto.auth.AuthenticationDto diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageParentController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageParentController.kt new file mode 100644 index 0000000000..73606bb620 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageParentController.kt @@ -0,0 +1,21 @@ +package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.parentauth.ExistenceLeakageParentApplication +import org.evomaster.client.java.controller.AuthUtils +import org.evomaster.client.java.controller.api.dto.auth.AuthenticationDto + +class ExistenceLeakageParentController : SpringController(ExistenceLeakageParentApplication::class.java) { + + override fun getInfoForAuthentication(): List { + return listOf( + AuthUtils.getForAuthorizationHeader("FOO","FOO"), + AuthUtils.getForAuthorizationHeader("BAR","BAR"), + ) + } + + override fun resetStateOfSUT() { + ExistenceLeakageParentApplication.reset() + } + +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageParentNoExistenceController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageParentNoExistenceController.kt new file mode 100644 index 0000000000..c31a4e880a --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/existenceleakage/ExistenceLeakageParentNoExistenceController.kt @@ -0,0 +1,22 @@ +package com.foo.rest.examples.spring.openapi.v3.security.existenceleakage + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.parentauth.ExistenceLeakageParentApplication +import com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.parentnoexistence.ExistenceLeakageParentNoExistenceApplication +import org.evomaster.client.java.controller.AuthUtils +import org.evomaster.client.java.controller.api.dto.auth.AuthenticationDto + +class ExistenceLeakageParentNoExistenceController : SpringController(ExistenceLeakageParentNoExistenceApplication::class.java) { + + override fun getInfoForAuthentication(): List { + return listOf( + AuthUtils.getForAuthorizationHeader("FOO","FOO"), + AuthUtils.getForAuthorizationHeader("BAR","BAR"), + ) + } + + override fun resetStateOfSUT() { + ExistenceLeakageParentNoExistenceApplication.reset() + } + +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/existenceleakage/SecurityExistenceLeakageNoExistenceEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/existenceleakage/SecurityExistenceLeakageNoExistenceEMTest.kt new file mode 100644 index 0000000000..4af66561fe --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/existenceleakage/SecurityExistenceLeakageNoExistenceEMTest.kt @@ -0,0 +1,46 @@ +package org.evomaster.e2etests.spring.openapi.v3.security.existenceleakage + +import com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.ExistenceLeakageParentNoExistenceController +import com.webfuzzing.commons.faults.DefinedFaultCategory +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class SecurityExistenceLeakageNoExistenceEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(ExistenceLeakageParentNoExistenceController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "SecurityExistenceLeakageNoExistenceEM", + 6000 + ) { args: MutableList -> + + setOption(args, "security", "true") + setOption(args, "schemaOracles", "false") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.GET, 404, "/api/parents/{pid}/children/{cid}", null) + + val faultCategories = DetectedFaultUtils.getDetectedFaultCategories(solution) + + assertFalse({ DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE in faultCategories }) + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/existenceleakage/SecurityExistenceLeakageParentEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/existenceleakage/SecurityExistenceLeakageParentEMTest.kt new file mode 100644 index 0000000000..677896b870 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/existenceleakage/SecurityExistenceLeakageParentEMTest.kt @@ -0,0 +1,51 @@ +package org.evomaster.e2etests.spring.openapi.v3.security.existenceleakage + +import com.foo.rest.examples.spring.openapi.v3.security.existenceleakage.ExistenceLeakageParentController +import com.webfuzzing.commons.faults.DefinedFaultCategory +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class SecurityExistenceLeakageParentEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(ExistenceLeakageParentController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "SecurityExistenceLeakageParentEM", + 100 + ) { args: MutableList -> + + setOption(args, "security", "true") + setOption(args, "schemaOracles", "false") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.PUT, 201, "/api/parents/{pid}", null) + assertHasAtLeastOne(solution, HttpVerb.PUT, 201, "/api/parents/{pid}/children/{cid}", null) + assertHasAtLeastOne(solution, HttpVerb.GET, 403, "/api/parents/{pid}/children/{cid}", null) + assertHasAtLeastOne(solution, HttpVerb.GET, 404, "/api/parents/{pid}/children/{cid}", null) + assertHasAtLeastOne(solution, HttpVerb.GET, 403, "/api/parents/{pid}", null) + + + val faults = DetectedFaultUtils.getDetectedFaultCategories(solution) + assertEquals(1, faults.size) + assertEquals(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE, faults.first()) + } + } +} diff --git a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt index 5fd6b70372..b8921dc364 100644 --- a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt +++ b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt @@ -82,7 +82,7 @@ class SecurityExistenceLeakageTest: IntegrationTestRestBase() { assertEquals(403, r1.getStatusCode()) assertEquals(404, r2.getStatusCode()) - val faultDetected = RestSecurityOracle.hasExistenceLeakage(RestPath("/api/resources/{id}"),ei.individual, ei.seeResults()) + val faultDetected = RestSecurityOracle.hasExistenceLeakage(RestPath("/api/resources/{id}"),ei.individual, ei.seeResults(), listOf()) assertTrue(faultDetected) //fault should be put on 404 diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestCallAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestCallAction.kt index c889d67ecf..b45d5d04b8 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestCallAction.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestCallAction.kt @@ -199,8 +199,24 @@ class RestCallAction( } fun usingSameResolvedPath(other: RestCallAction) = + //FIXME this does not consider dynamic fields? this.resolvedOnlyPath() == other.resolvedOnlyPath() + /** + * When the URL path of this endpoint is resolved, would it be a (strict) parent from the other action + */ + fun isResolvedParentPath(other: RestCallAction): Boolean { + + val parent = this.resolvedOnlyPath() // TODO deal with dynamic info + val child = other.resolvedOnlyPath() + + if(parent.length >= child.length) { + return false + } + return child.startsWith(parent) + } + + /** Note: in swagger the "consume" type might be missing. So, if for any reason there is a form param, then consider diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestPath.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestPath.kt index 7d3901b8d6..8331f87ca2 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestPath.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/data/RestPath.kt @@ -243,6 +243,14 @@ class RestPath(path: String) { return other.isSameOrAncestorOf(this) } + fun isStrictlyAncestorOf(other: RestPath): Boolean { + if(this.elements.size == other.elements.size && (this.endsWithSlash || !other.endsWithSlash)){ + //if same size, then only possibility of being ancestor if other ends with slash, but not this + return false + } + return isSameOrAncestorOf(other) + } + /** * Prefix or same as "other" @@ -591,6 +599,20 @@ class RestPath(path: String) { fun isRoot() = levels() == 0 + /** + * Checks if this path has an ancestor (parent path). + * Returns true if the path has more than one level AND ends with a parameter. + * + * Examples: + * - /{id}/child/{cid} returns true (has ancestor and ends with parameter) + * - /{id} returns false (single level, no ancestor) + * - /{id}/child returns false (does not end with parameter) + * - /{id}/child/ returns false (does not end with parameter) + * - /users/{id} returns true (has ancestor and ends with parameter) + * - /users returns false (single level, no ancestor) + */ + fun hasAncestorAndLastElementParameter(): Boolean = levels() > 1 && isLastElementAParameter() + fun parentPath() : RestPath { if(isRoot()){ throw IllegalStateException("Root has no parent") diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt index 0bb446ab32..5cba1020a9 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt @@ -171,7 +171,8 @@ object RestSecurityOracle { fun hasExistenceLeakage( path: RestPath, individual: RestIndividual, - actionResults: List + actionResults: List, + actionDefinitions: List ): Boolean{ verifySampleType(individual) @@ -201,7 +202,68 @@ object RestSecurityOracle { .getStatusCode() == 404 } - return a403.isNotEmpty() && a404.isNotEmpty() + if(a403.isEmpty() || a404.isEmpty()){ + //no discrepancy of status on same path. so no leakage + return false + } + + /* + by itself, the fact that there is 404 does not imply a leakage, as the user + "might" own parent resources. + we need to check for that. + */ + + val topGet = findStrictTopGETResourceAncestor(path, actionDefinitions) + //if null, then for sure the user with 404 does not own a parent resource + ?: return true + + val verifiers = individual.seeMainExecutableActions() + .filter { it.verb == HttpVerb.GET && it.path == topGet.path } + .filter { actionResults.find { r -> r.sourceLocalId == it.getLocalId() } != null } + + if(verifiers.isEmpty()){ + //a top GET exists in schema, but was not called in the test. + //as such, we cannot be sure if bug is found, as, even if 403-404 on same path, + //it could well be that the 404 was legit + return false + } + + for(notfound in a404){ + + //FIXME i don't think it is correct, as ignoring dynamic info? + //TODO need tests for it + val matching = verifiers.filter { + it.isResolvedParentPath(notfound) + && ! notfound.auth.isDifferentFrom(it.auth) + } + + if(matching.isEmpty()){ + continue + } + + val codes = matching.map { (actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult).getStatusCode() } + + if(codes.any{ StatusGroup.G_2xx.isInGroup(it)}) { + //a 2xx can be done on parent resource + continue + } + + if(codes.any{ it == 403 || it == 404 }) { + //there is at least one call on ancestor resource with same auth, but none was positive 2xx + return true // and there is at least one discrepancy 403-404 on same endpoint + } + //other codes like 400 or 500 are ignored here, eg, due to input validation + } + + return false + } + + private fun findStrictTopGETResourceAncestor(path: RestPath, actions: List) : RestCallAction?{ + return actions + .filter { it.verb == HttpVerb.GET } + .filter { it.path.isStrictlyAncestorOf(path)} + .filter { it.path.isLastElementAParameter() } + .minByOrNull { it.path.levels() } } /** diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt index 78572ab1eb..922bf5d685 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt @@ -227,8 +227,8 @@ class SecurityRest { */ val repeat = lastCall.copy() as RestCallAction repeat.forceNewTaints() + repeat.resetLocalIdRecursively() copy.addMainActionInEmptyEnterpriseGroup(action = repeat) - copy.resetLocalIdRecursively() //TODO what about links? copy.doInitializeLocalId() } copy.seeMainExecutableActions().last().auth = otherAuth @@ -388,7 +388,7 @@ class SecurityRest { var anySuccess = false genes.forEach { - gene -> + gene -> val leafGene = gene.getLeafGene() if(leafGene !is StringGene) return@forEach @@ -398,7 +398,7 @@ class SecurityRest { // we need to do this way because we need to append our payload var newPayload = leafGene.getPhenotype().getValueAsRawString() + String.format(payload, config.sqliInjectedSleepDurationMs/1000.0) - // append the SQLi payload value + // append the SQLi payload value leafGene.getPhenotype().setFromStringValue(newPayload).also { if(it) anySuccess = true } @@ -599,7 +599,7 @@ class SecurityRest { action.path, status = 401, authenticated = true - ) + ) }.distinctBy { it.seeMainExecutableActions().last().auth.name } if(suspicious.isEmpty()){ @@ -682,17 +682,17 @@ class SecurityRest { getOperations.forEach { get -> - val inds403 = RestIndividualSelectorUtils.findIndividuals( + val inds403 = RestIndividualSelectorUtils.findAndSlice( individualsInSolution, HttpVerb.GET, get.path, status = 403 - ) + ) if(inds403.isEmpty()){ return@forEach } - val inds404 = RestIndividualSelectorUtils.findIndividuals( + val inds404 = RestIndividualSelectorUtils.findAndSlice( individualsInSolution, HttpVerb.GET, get.path, @@ -703,29 +703,40 @@ class SecurityRest { } //found the bug. - val forbidden = inds403.minBy { it.individual.size() } - val notfound = inds404.maxBy { it.individual.size() } - - //needs slicing to minimize the newly generated test - val index403 = RestIndividualSelectorUtils.getIndexOfAction( - forbidden, - HttpVerb.GET, - get.path, - 403 - ) - // slice the individual in a way that delete all calls after the chosen verb request - val first = RestIndividualBuilder.sliceAllCallsInIndividualAfterAction(forbidden.individual, index403) - - val index404 = RestIndividualSelectorUtils.getIndexOfAction( - notfound, - HttpVerb.GET, - get.path, - 404 - ) - // slice the individual in a way that delete all calls after the chosen verb request - val second = RestIndividualBuilder.sliceAllCallsInIndividualAfterAction(notfound.individual, index404) - - val final = RestIndividualBuilder.merge(first, second) + val forbidden = inds403.minBy { it.size() } + val notfound = inds404.minBy { it.size() } + + val final = RestIndividualBuilder.merge(forbidden, notfound) + + // Check if the 404 path is a child resource and add parent GET to check access + val path404 = get.path + val action404 = final.seeMainExecutableActions().last() + val lastAuth = action404.auth + + // Find the parent path from getOperations (all GET operation definitions) + val parentGetOperation = getOperations + .filter { it.path.isStrictlyAncestorOf(path404)} + .filter { it.path.isLastElementAParameter() } + .minByOrNull { it.path.levels() } + + if (parentGetOperation != null) { + val parentGetAction = parentGetOperation.copy() as RestCallAction + parentGetAction.resetLocalIdRecursively() + parentGetAction.forceNewTaints() + + parentGetAction.doInitialize() + parentGetAction.auth = lastAuth + // Bind to the same path params from the 404 action to ensure same IDs + //FIXME this would currently not work for dynamic parameters + parentGetAction.bindToSamePathResolution(action404) + + final.addResourceCall( + restCalls = RestResourceCalls( + actions = mutableListOf(parentGetAction), + sqlActions = listOf() + ) + ) + } final.modifySampleType(SampleType.SECURITY) final.ensureFlattenedStructure() @@ -736,31 +747,15 @@ class SecurityRest { return@forEach } - //verify if newly constructed individual still find the bug - val check403 = RestIndividualSelectorUtils.getIndexOfAction( - evaluatedIndividual, - HttpVerb.GET, - get.path, - 403 - ) - val check404 = RestIndividualSelectorUtils.getIndexOfAction( - evaluatedIndividual, - HttpVerb.GET, - get.path, - 404 - ) - //fitness function should have detected the fault - val faults = (evaluatedIndividual.evaluatedMainActions().last().result as RestCallResult).getFaults() + val faultsCategories = DetectedFaultUtils.getDetectedFaultCategories(evaluatedIndividual) - if(check403 < 0 || check404 < 0 || faults.none { it.category == DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE }){ - //if this happens, it is a bug in the merge... or flakiness - log.warn("Failed to construct new test showing the 403 vs 404 security leakage issue") - return@forEach + if(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE in faultsCategories){ + // add the evaluated individual to the archive + val added = archive.addIfNeeded(evaluatedIndividual) + //if we arrive here, should always be added, because we are creating a new testing target + assert(added) } - val added = archive.addIfNeeded(evaluatedIndividual) - //if we arrive here, should always be added, because we are creating a new testing target - assert(added) } } @@ -1060,8 +1055,8 @@ class SecurityRest { ) finalIndividual.seeMainExecutableActions() .filter { it.verb == HttpVerb.PUT || it.verb == HttpVerb.POST }.forEach { - it.saveCreatedResourceLocation = true - } + it.saveCreatedResourceLocation = true + } finalIndividual.fixResourceForwardLinks() finalIndividual.modifySampleType(SampleType.SECURITY) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt index d3bf0f01c7..99024afd52 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt @@ -108,9 +108,15 @@ abstract class AbstractRestFitness : HttpWsFitness() { private lateinit var schemaOracle: RestSchemaOracle + /** + * All actions that can be defined from the OpenAPI schema + */ + private lateinit var actionDefinitions: List + @PostConstruct fun initBean(){ schemaOracle = RestSchemaOracle((sampler as AbstractRestSampler).schemaHolder) + actionDefinitions = sampler.getActionDefinitions() as List } @@ -1361,7 +1367,9 @@ abstract class AbstractRestFitness : HttpWsFitness() { .map { it.path } .toSet() - val faultyPaths = getPaths.filter { RestSecurityOracle.hasExistenceLeakage(it, individual, actionResults) } + val faultyPaths = getPaths.filter { + RestSecurityOracle.hasExistenceLeakage(it, individual, actionResults, actionDefinitions) + } if(faultyPaths.isEmpty()){ return }