diff --git a/README.md b/README.md index 1cec985add..8bde28bc04 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ To populate the OBP database with sandbox data: ## Production Options -OBP-API runs on http4s Ember. Standard security headers (Cache-Control, X-Frame-Options, Correlation-Id, etc.) are applied automatically by `Http4sStandardHeaders` (wired into `Http4sApp.httpApp`) to all responses. Cookie flags and other session-related settings can be configured via the props file. +OBP-API runs on http4s Ember. Standard security headers (Cache-Control, X-Frame-Options, X-Content-Type-Options, Correlation-Id, etc.) are applied automatically by `Http4sStandardHeaders` (wired into `Http4sApp.httpApp`) to all responses. Cookie flags and other session-related settings can be configured via the props file. ## Server Mode Configuration (Removed) diff --git a/obp-api/pom.xml b/obp-api/pom.xml index 2d5203ced5..fc8fcaac90 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -357,6 +357,13 @@ 1.17.1 + + + org.jsoup + jsoup + 1.18.3 + org.reflections diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index 3d66491005..e86d38b42e 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -2697,7 +2697,7 @@ super_admin_user_ids=uuid-1,uuid-2 OBP can bootstrap a Consumer (Application) for OBP-OIDC at startup. This allows OBP-OIDC to authenticate as an application (without a User) and manage consumers via the API, eliminating the need for direct database access. -The bootstrap consumer is granted the following Scopes: `CanGetConsumers`, `CanCreateConsumer`, `CanVerifyOidcClient`, `CanGetOidcClient`. +The bootstrap consumer is granted the following Scopes: `CanGetConsumers`, `CanCreateConsumer`, `CanVerifyOidcClient`, `CanGetOidcClient`, `CanGetOidcConsent`. These endpoints use `authMode = UserOrApplication`, meaning they can be accessed either by a logged-in User with Entitlements, or by an Application using a Consumer Key with Scopes. diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index aad033ee35..162debf7bb 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -872,7 +872,7 @@ class Boot extends MdcLoggable { /** * Bootstrap OIDC Operator User * Given the following credentials, OBP will create a user *if it does not exist already*. - * This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers, CanCreateConsumer + * This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetOidcConsent, CanGetConsumers, CanCreateConsumer */ private def createBootstrapOidcOperatorUser() = { @@ -912,6 +912,7 @@ class Boot extends MdcLoggable { CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, + CanGetOidcConsent, CanGetConsumers, CanCreateConsumer ) @@ -934,7 +935,7 @@ class Boot extends MdcLoggable { /** * Bootstrap OIDC Operator Consumer * Given the following key and secret, OBP will create a consumer *if it does not exist already*. - * This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient + * This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient, CanGetOidcConsent * This allows OBP-OIDC to authenticate as an application (without a user) and manage consumers via the API. */ private def createBootstrapOidcOperatorConsumer() = { @@ -982,7 +983,7 @@ class Boot extends MdcLoggable { consumerBox match { case Full(consumer) => logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer created successfully with consumer_id: ${consumer.consumerId.get}") - val scopes = List(CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient) + val scopes = List(CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient, CanGetOidcConsent) scopes.foreach { role => val resultBox = Scope.scope.vend.addScope("", consumer.id.get.toString, role.toString) if (resultBox.isEmpty) { diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 80b8e452ba..0ee0e4e564 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -32,6 +32,7 @@ import code.consumer.Consumers import code.consumer.Consumers.consumers import code.loginattempts.LoginAttempt import code.model.{AppType, Consumer} +import code.model.dataAccess.AuthUser import code.scope.Scope import code.users.Users import code.util.Helper.MdcLoggable @@ -41,6 +42,7 @@ import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.User import net.liftweb.common.Box.tryo import net.liftweb.common._ +import net.liftweb.mapper.By import net.liftweb.util.Helpers import org.apache.commons.lang3.StringUtils @@ -428,7 +430,11 @@ object OAuth2Login extends MdcLoggable { def getOrCreateResourceUser(jwtToken: String): Box[User] = { val uniqueIdGivenByProvider = JwtUtil.getSubject(jwtToken).getOrElse("") val provider = resolveProvider(jwtToken) - KeycloakFederatedUserReference.parse(uniqueIdGivenByProvider) match { + // Only Keycloak issues the federated form. From any other issuer it would name an arbitrary user_id. + val federatedRef = + if (Keycloak.isIssuer(jwtToken)) KeycloakFederatedUserReference.parse(uniqueIdGivenByProvider) + else Left("not issued by Keycloak") + federatedRef match { case Right(fedRef) => // Users log on via Keycloak, which uses User Federation to access the external OBP database. logger.debug(s"External ID = ${fedRef.externalId}") logger.debug(s"Storage Provider ID = ${fedRef.storageProviderId}") @@ -452,13 +458,17 @@ object OAuth2Login extends MdcLoggable { } def resolveProvider(jwtToken: String) = { - // First try to get provider from token's provider claim + // First try to get provider from token's provider claim. Only OBP's own identity providers, which read + // OBP's user store, may name it: from any other issuer it could point a token at someone else's user. val providerFromToken = JwtUtil.getProvider(jwtToken) - + .filter(_ => OBPOIDC.isIssuer(jwtToken) || Keycloak.isIssuer(jwtToken)) + providerFromToken.filter(_.trim.nonEmpty) match { case Some(provider) => logger.debug(s"resolveProvider says: using provider from token claim: $provider") provider + case None if OBPOIDC.isIssuer(jwtToken) && isClientCredentials(jwtToken) => + clientCredentialsProvider(jwtToken) case None if OBPOIDC.isIssuer(jwtToken) => // OBP-OIDC authenticates against the OBP-API user store, so its users ARE local users — // use the local provider to avoid creating a duplicate user during the OIDC flow. @@ -471,6 +481,29 @@ object OAuth2Login extends MdcLoggable { } } + /** A token with no person behind it: OBP-OIDC puts the client id in sub and names no provider. */ + def isClientCredentials(jwtToken: String): Boolean = + getClaim(name = "grant_type", jwtToken = jwtToken).contains("client_credentials") || + JwtUtil.getSubject(jwtToken).exists(sub => sub.nonEmpty && getClaim(name = "azp", jwtToken = jwtToken).contains(sub)) + + /** Where an app's own user lives. Not the local provider, where anyone may sign up with a username equal + * to a client id and then be the user everything the app does is recorded as. An app that already has a + * user there keeps it, unless that user is a person who signed up with the client id as username. */ + private def clientCredentialsProvider(jwtToken: String): String = { + val clientId = JwtUtil.getSubject(jwtToken).getOrElse("") + val local = Constant.localIdentityProvider + lazy val localUser = Users.users.vend.getUserByProviderId(provider = local, idGivenByProvider = clientId) + lazy val signedUp = localUser.isDefined && + AuthUser.find(By(AuthUser.username, clientId), By(AuthUser.provider, local)).isDefined + if (Users.users.vend.getUserByProviderId(provider = OBPOIDC.clientCredentialsProvider, idGivenByProvider = clientId).isDefined) + OBPOIDC.clientCredentialsProvider + else if (localUser.isDefined && !signedUp) local + else { + if (signedUp) logger.warn(s"resolveProvider says: a person signed up with the client id $clientId as username; the client gets a user of its own") + OBPOIDC.clientCredentialsProvider + } + } + /** * This function resolves the token to a consumer based on the "azp", "aud", "iss", "sub", * "name" and "email" claims. Please note that a user must be created before consumer. @@ -768,6 +801,8 @@ object OAuth2Login extends MdcLoggable { object OBPOIDC extends OAuth2Util { val obpOidcHost = APIUtil.getPropsValue(nameOfProperty = "oauth2.obp_oidc.host", "http://localhost:9000") val obpOidcIssuer = "obp-oidc" + // The provider of the users OBP-OIDC's client-credentials tokens resolve to: one no person can sign up under. + val clientCredentialsProvider = "obp-oidc#client" /** * OBP-OIDC (Open Bank Project OIDC Provider) * OBP-OIDC exposes OpenID Connect discovery documents at /.well-known/openid-configuration diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 14d1e2309e..b93dabc9ed 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2252,7 +2252,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // Virtual roles granted by super_admin_user_ids prop val superAdminVirtualRoles: List[String] = List("CanCreateEntitlementAtOneBank", "CanCreateEntitlementAtAnyBank", "CanGetAnyUser") // Virtual roles granted by oidc_operator_user_ids prop - val oidcOperatorVirtualRoles: List[String] = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient", "CanGetConsumers", "CanCreateConsumer") + val oidcOperatorVirtualRoles: List[String] = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient", "CanGetOidcConsent", "CanGetConsumers", "CanCreateConsumer") def hasScope(bankId: String, consumerId: String, role: ApiRole): Boolean = { !Scope.scope.vend.getScope(bankId, consumerId, role.toString).isEmpty diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index b68b73f314..3e61f5037b 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -345,6 +345,9 @@ object ApiRole extends MdcLoggable{ case class CanGetOidcClient(requiresBankId: Boolean = false) extends ApiRole lazy val canGetOidcClient = CanGetOidcClient() + case class CanGetOidcConsent(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetOidcConsent = CanGetOidcConsent() + case class CanCreateTestEmail(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateTestEmail = CanCreateTestEmail() diff --git a/obp-api/src/main/scala/code/api/util/PegdownOptions.scala b/obp-api/src/main/scala/code/api/util/PegdownOptions.scala index 48f918958c..29405db5d2 100644 --- a/obp-api/src/main/scala/code/api/util/PegdownOptions.scala +++ b/obp-api/src/main/scala/code/api/util/PegdownOptions.scala @@ -32,6 +32,11 @@ import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.profiles.pegdown.Extensions import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter import com.vladsch.flexmark.util.options.{DataHolder, MutableDataSet} +import org.jsoup.Jsoup +import org.jsoup.nodes.{Document, Entities} +import org.jsoup.safety.Safelist + +import scala.util.matching.Regex object PegdownOptions { @@ -41,20 +46,62 @@ object PegdownOptions { private val OPTIONS: DataHolder = PegdownOptionsAdapter.flexmarkOptions(Extensions.ALL & ~Extensions.SMARTYPANTS) private val PARSER: Parser = Parser.builder(OPTIONS).build private val RENDERER: HtmlRenderer = HtmlRenderer.builder(OPTIONS).build - + + // Descriptions of Dynamic Entities, Dynamic Resource Docs, Dynamic Endpoints and Glossary Items are + // written through the API, and the HTML rendered from them is shown to every API Explorer visitor. + // Markdown passes raw HTML through, so without this a description could carry a \n\nAfter") + stringToNodeSeq(descriptionHtml) + descriptionHtml should not include ("""") + stringToNodeSeq(descriptionHtml) + descriptionHtml should include ("src=\"https://example.com/a.png\"") + descriptionHtml should not include ("onerror") + } + + "a javascript link" should "lose its href" taggedAs FunctionsTag in { + val markdownLink = convertPegdownToHtmlTweaked("[click](javascript:alert(1))") + val htmlLink = convertPegdownToHtmlTweaked("""click""") + markdownLink should not include ("javascript:") + htmlLink should not include ("javascript:") + htmlLink should include ("click") + } + + "a sized image whose url contains a double quote" should "not break out of the src attribute" taggedAs FunctionsTag in { + val descriptionHtml = convertPegdownToHtmlTweaked("""![logo](https://example.com/a.png" onload="alert(1) =10x20)""") + val image = stringToNodeSeq(descriptionHtml) \\ "img" + image.size should be (1) + // The quote is escaped, so onload stays text inside src rather than becoming an attribute. + image.head.attribute("onload") should be (None) + image.head \@ "src" should be ("https://example.com/a.png\" onload=\"alert(1)") + image.head \@ "width" should be ("10") + } + + "formatting that descriptions use on purpose" should "survive sanitising" taggedAs FunctionsTag in { + val descriptionHtml = convertPegdownToHtmlTweaked( + """
More + | + |

Inside a link

+ | + |
+ | + |[BANK_ID](/glossary#Bank.bank_id) and [Contact](#Contact) + | + |```json + |{ + | "indented": true + |} + |``` + | + || a | b | + ||---|---| + || 1 | 2 | + | + |~~gone~~ and line
break + |""".stripMargin) + stringToNodeSeq(descriptionHtml) + descriptionHtml should include ("
") + descriptionHtml should include ("More") + descriptionHtml should include ("a link") + descriptionHtml should include ("BANK_ID") + descriptionHtml should include ("Contact") + descriptionHtml should include ("\n \"indented\": true\n") + descriptionHtml should include ("") + descriptionHtml should include ("gone") + descriptionHtml should include ("
") + } }