Skip to content

Commit

Permalink
Merge pull request #346 from newrelic/support/http-blaze-client
Browse files Browse the repository at this point in the history
Instrumentation Support for HTTP4s Blaze client
  • Loading branch information
IshikaDawda authored Dec 12, 2024
2 parents 26694d6 + 2c27d2c commit 40a3a6d
Show file tree
Hide file tree
Showing 31 changed files with 1,705 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
apply plugin: 'scala'

isScalaProjectEnabled(project, "scala-2.12")

dependencies {
implementation(project(":newrelic-security-api"))
implementation("com.newrelic.agent.java:newrelic-api:${nrAPIVersion}")
implementation("com.newrelic.agent.java:newrelic-weaver-api:${nrAPIVersion}")
implementation("org.scala-lang:scala-library:2.12.14")
implementation('org.http4s:http4s-blaze-client_2.12:0.21.24')
implementation("org.typelevel:cats-effect_2.12:2.5.5")
}

jar {
manifest {
attributes 'Implementation-Title': 'com.newrelic.instrumentation.security.http4s-blaze-client-2.12_0.21', 'Priority': '-1'
}
}

verifyInstrumentation {
passes 'org.http4s:http4s-blaze-client_2.12:[0.21,0.22)'
excludeRegex '.*(RC|M)[0-9]*'
}

sourceSets.main.scala.srcDirs = ['src/main/scala', 'src/main/java']
sourceSets.main.java.srcDirs = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.http4s;

import cats.effect.ConcurrentEffect;
import cats.effect.Resource;
import com.newrelic.agent.security.instrumentation.http4s.blaze.NewrelicSecurityClientMiddleware$;
import com.newrelic.api.agent.weaver.MatchType;
import com.newrelic.api.agent.weaver.Weave;
import com.newrelic.api.agent.weaver.Weaver;
import org.http4s.client.Client;

@Weave(type = MatchType.ExactClass, originalName = "org.http4s.client.blaze.BlazeClientBuilder")
public abstract class BlazeClientBuilder_Instrumentation<F> {

public ConcurrentEffect F() {
return Weaver.callOriginal();
}

public Resource<F, Client<F>> resource() {
Resource<F, Client<F>> delegateResource = Weaver.callOriginal();
return NewrelicSecurityClientMiddleware$.MODULE$.resource(delegateResource, F());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package com.newrelic.agent.security.instrumentation.http4s.blaze

import cats.effect.{Async, ConcurrentEffect, Resource, Sync}
import com.newrelic.api.agent.security.NewRelicSecurity
import com.newrelic.api.agent.security.instrumentation.helpers.{GenericHelper, ServletHelper}
import com.newrelic.api.agent.security.schema.exceptions.NewRelicSecurityException
import com.newrelic.api.agent.security.schema.operation.SSRFOperation
import com.newrelic.api.agent.security.schema.{AbstractOperation, StringUtils, VulnerabilityCaseType}
import com.newrelic.api.agent.security.utils.SSRFUtils
import com.newrelic.api.agent.security.utils.logging.LogLevel
import org.http4s.Request
import org.http4s.client.Client

import java.net.URI

object NewrelicSecurityClientMiddleware {
private final val nrSecCustomAttrName: String = "HTTP4S-BLAZE-CLIENT-OUTBOUND"
private final val HTTP4S_BLAZE_CLIENT: String = "HTTP4S-BLAZE-CLIENT-2.12_0.21"

private def construct[F[_] : Sync, T](t: T): F[T] = Sync[F].delay(t)

private def clientResource[F[_] : ConcurrentEffect](client: Client[F]): Client[F] =
Client { req: Request[F] =>
for {
// pre-process hook
operation <- Resource.liftF(construct {
val isLockAcquired = GenericHelper.acquireLockIfPossible(VulnerabilityCaseType.HTTP_REQUEST, nrSecCustomAttrName)
var operation: AbstractOperation = null
if (isLockAcquired) {
operation = preprocessSecurityHook(req)
}
operation
})

request <- Resource.liftF(construct {addSecurityHeaders(req, operation)})

// original call
response <- client.run(request)

// post process and register exit event
newRes <- Resource.liftF(construct{
val isLockAcquired = GenericHelper.isLockAcquired(nrSecCustomAttrName);
if (isLockAcquired) {
GenericHelper.releaseLock(nrSecCustomAttrName)
}
registerExitOperation(isLockAcquired, operation)
response
})

} yield newRes
}

def resource[F[_] : ConcurrentEffect](delegate: Resource[F, Client[F]]): Resource[F, Client[F]] = {
val res: Resource[F, Client[F]] = delegate.map(c =>clientResource(c))
res
}

private def addSecurityHeaders[F[_] : Async](request: Request[F], operation: AbstractOperation): Request[F] = {
val outboundRequest = new OutboundRequest(request)
if (operation != null) {
val securityMetaData = NewRelicSecurity.getAgent.getSecurityMetaData
val iastHeader = NewRelicSecurity.getAgent.getSecurityMetaData.getFuzzRequestIdentifier.getRaw
if (iastHeader != null && iastHeader.trim.nonEmpty) {
outboundRequest.setHeader(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID, iastHeader)
}
val csecParentId = securityMetaData.getCustomAttribute(GenericHelper.CSEC_PARENT_ID, classOf[String])
if (StringUtils.isNotBlank(csecParentId)) {
outboundRequest.setHeader(GenericHelper.CSEC_PARENT_ID, csecParentId)
}
try {
NewRelicSecurity.getAgent.getSecurityMetaData.getMetaData.setFromJumpRequiredInStackTrace(Integer.valueOf(4))
NewRelicSecurity.getAgent.registerOperation(operation)
}
finally {
if (operation.getApiID != null && operation.getApiID.trim.nonEmpty && operation.getExecutionId != null && operation.getExecutionId.trim.nonEmpty) {
outboundRequest.setHeader(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER, SSRFUtils.generateTracingHeaderValue(securityMetaData.getTracingHeaderValue, operation.getApiID, operation.getExecutionId, NewRelicSecurity.getAgent.getAgentUUID))
}
}
}
outboundRequest.getRequest
}


private def preprocessSecurityHook[F[_] : Async](httpRequest: Request[F]): AbstractOperation = {
try {
val securityMetaData = NewRelicSecurity.getAgent.getSecurityMetaData
if (!NewRelicSecurity.isHookProcessingActive || securityMetaData.getRequest.isEmpty) return null
// Generate required URL
var methodURI: URI = null
var uri: String = null
try {
methodURI = new URI(httpRequest.uri.toString)
uri = methodURI.toString
if (methodURI == null) return null
} catch {
case ignored: Exception =>
NewRelicSecurity.getAgent.log(LogLevel.WARNING, String.format(GenericHelper.URI_EXCEPTION_MESSAGE, HTTP4S_BLAZE_CLIENT, ignored.getMessage), ignored, this.getClass.getName)
return null
}
return new SSRFOperation(uri, this.getClass.getName, "run")
} catch {
case e: Throwable =>
if (e.isInstanceOf[NewRelicSecurityException]) {
NewRelicSecurity.getAgent.log(LogLevel.WARNING, String.format(GenericHelper.SECURITY_EXCEPTION_MESSAGE, HTTP4S_BLAZE_CLIENT, e.getMessage), e, this.getClass.getName)
throw e
}
NewRelicSecurity.getAgent.log(LogLevel.SEVERE, String.format(GenericHelper.REGISTER_OPERATION_EXCEPTION_MESSAGE, HTTP4S_BLAZE_CLIENT, e.getMessage), e, this.getClass.getName)
NewRelicSecurity.getAgent.reportIncident(LogLevel.SEVERE, String.format(GenericHelper.REGISTER_OPERATION_EXCEPTION_MESSAGE, HTTP4S_BLAZE_CLIENT, e.getMessage), e, this.getClass.getName)
}
null
}

private def registerExitOperation(isProcessingAllowed: Boolean, operation: AbstractOperation): Unit = {
try {
if (operation == null || !isProcessingAllowed || !NewRelicSecurity.isHookProcessingActive || NewRelicSecurity.getAgent.getSecurityMetaData.getRequest.isEmpty) return
NewRelicSecurity.getAgent.registerExitEvent(operation)
} catch {
case e: Throwable =>
NewRelicSecurity.getAgent.log(LogLevel.FINEST, String.format(GenericHelper.EXIT_OPERATION_EXCEPTION_MESSAGE, HTTP4S_BLAZE_CLIENT, e.getMessage), e, this.getClass.getName)
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.newrelic.agent.security.instrumentation.http4s.blaze

import org.http4s.util.CaseInsensitiveString
import org.http4s.{Header, Request}

/**
* Http4s's HttpRequest is immutable so we have to create a copy with the new headers.
*/

class OutboundRequest[F[_]](request: Request[F]) {
private var req: Request[F] = request

def setHeader(key: String, value: String): Unit = {
req = req.withHeaders(req.headers.put(Header.Raw.apply(CaseInsensitiveString.apply(key), value)))
}
def getRequest: Request[F] = {
req
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.nr.agent.security.instrumentation.blaze.client

import cats.effect.{ConcurrentEffect, ContextShift, IO, Timer}
import com.newrelic.agent.security.introspec.internal.HttpServerRule
import com.newrelic.agent.security.introspec.{InstrumentationTestConfig, SecurityInstrumentationTestRunner, SecurityIntrospector}
import com.newrelic.api.agent.security.instrumentation.helpers.{GenericHelper, ServletHelper}
import com.newrelic.api.agent.security.schema.operation.SSRFOperation
import com.newrelic.api.agent.security.schema.{AbstractOperation, VulnerabilityCaseType}
import com.nr.agent.security.instrumentation.blaze.client.Http4sTestUtils.makeRequest
import org.http4s.client.blaze.BlazeClientBuilder
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import org.junit.{Assert, FixMethodOrder, Rule, Test}

import java.util
import java.util.UUID
import scala.concurrent.ExecutionContext
import scala.concurrent.ExecutionContext.global
import scala.concurrent.duration.DurationInt

@RunWith(classOf[SecurityInstrumentationTestRunner])
@InstrumentationTestConfig(includePrefixes = Array("org.http4s", "com.newrelic.agent.security.instrumentation.http4s"))
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class BlazeClientTest {

@Rule
def server: HttpServerRule = httpServer

implicit val ec: ExecutionContext = global
implicit val cs: ContextShift[IO] = IO.contextShift(global)
implicit val timer: Timer[IO] = IO.timer(global)

val httpServer = new HttpServerRule()

@Test
def blazeClientTest(): Unit = {

val introspector: SecurityIntrospector = SecurityInstrumentationTestRunner.getIntrospector
makeRequest(s"${server.getEndPoint}").unsafeRunTimed(2.seconds)
assertSSRFOperation(introspector.getOperations)

}

@Test
def blazeClientTestWithHeaders(): Unit = {
val headerValue = String.valueOf(UUID.randomUUID)

val introspector: SecurityIntrospector = SecurityInstrumentationTestRunner.getIntrospector
setCSECHeaders(headerValue = headerValue, introspector = introspector)
makeRequest(s"${server.getEndPoint}").unsafeRunTimed(2.seconds)
assertSSRFOperation(introspector.getOperations)
verifyHeaders(headerValue, httpServer.getHeaders)
}


private def assertSSRFOperation(operations: util.List[AbstractOperation]): Unit = {
Assert.assertTrue("Incorrect number of operations detected!", operations.size == 1)
Assert.assertTrue("SSRFOperation not found!", operations.get(0).isInstanceOf[SSRFOperation])
val operation: SSRFOperation = operations.get(0).asInstanceOf[SSRFOperation]

Assert.assertFalse("operation should not be empty", operation.isEmpty)
Assert.assertFalse("JNDILookup should be false", operation.isJNDILookup)
Assert.assertFalse("LowSeverityHook should be disabled", operation.isLowSeverityHook)
Assert.assertEquals("Invalid event category.", VulnerabilityCaseType.HTTP_REQUEST, operation.getCaseType)
Assert.assertEquals("Invalid executed method name.", "run", operation.getMethodName)
Assert.assertEquals("Invalid executed parameters.", server.getEndPoint.toString, operation.getArg)
}

private def verifyHeaders(headerValue: String, headers: util.Map[String, String]): Unit = {
Assert.assertTrue(String.format("Missing CSEC header: %s", ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID), headers.containsKey(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID))
Assert.assertEquals(String.format("Invalid CSEC header value for: %s", ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID), headerValue + "a", headers.get(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID))
Assert.assertTrue(String.format("Missing CSEC header: %s", GenericHelper.CSEC_PARENT_ID), headers.containsKey(GenericHelper.CSEC_PARENT_ID))
Assert.assertEquals(String.format("Invalid CSEC header value for: %s", GenericHelper.CSEC_PARENT_ID), headerValue + "b", headers.get(GenericHelper.CSEC_PARENT_ID))
Assert.assertTrue(String.format("Missing CSEC header: %s", ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER), headers.containsKey(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER.toLowerCase))
Assert.assertEquals(String.format("Invalid CSEC header value for: %s", ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER), String.format("%s;DUMMY_UUID/dummy-api-id/dummy-exec-id;", headerValue), headers.get(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER.toLowerCase))
}

private def setCSECHeaders(headerValue: String, introspector: SecurityIntrospector): Unit = {
introspector.setK2FuzzRequestId(headerValue + "a")
introspector.setK2ParentId(headerValue + "b")
introspector.setK2TracingData(headerValue)
}
}

object Http4sTestUtils {
def makeRequest[F[_] : ContextShift : Timer](url: String)(
implicit ex: ExecutionContext, c: ConcurrentEffect[F]): F[String] = {
BlazeClientBuilder[F](ex).resource.use { client =>
client.expect[String](url)
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
apply plugin: 'scala'

isScalaProjectEnabled(project, "scala-2.12")

dependencies {
implementation(project(":newrelic-security-api"))
implementation("com.newrelic.agent.java:newrelic-api:${nrAPIVersion}")
implementation("com.newrelic.agent.java:newrelic-weaver-api:${nrAPIVersion}")
implementation("org.scala-lang:scala-library:2.12.14")
implementation('org.http4s:http4s-blaze-client_2.12:0.22.14')
implementation("org.typelevel:cats-effect_2.12:2.5.5")
}

jar {
manifest {
attributes 'Implementation-Title': 'com.newrelic.instrumentation.security.http4s-blaze-client-2.12_0.22', 'Priority': '-1'
}
}

verifyInstrumentation {
passes 'org.http4s:http4s-blaze-client_2.12:[0.22.0,0.23.0)'
excludeRegex '.*(RC|M)[0-9]*'
excludeRegex '.*0.22\\-[0-9].*'
}

sourceSets.main.scala.srcDirs = ['src/main/scala', 'src/main/java']
sourceSets.main.java.srcDirs = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.http4s;

import cats.effect.ConcurrentEffect;
import cats.effect.Resource;
import com.newrelic.agent.security.instrumentation.http4s.blaze.NewrelicSecurityClientMiddleware$;
import com.newrelic.api.agent.weaver.MatchType;
import com.newrelic.api.agent.weaver.Weave;
import com.newrelic.api.agent.weaver.Weaver;
import org.http4s.client.Client;

@Weave(type = MatchType.ExactClass, originalName = "org.http4s.blaze.client.BlazeClientBuilder")
public abstract class BlazeClientBuilder_Instrumentation<F> {

public ConcurrentEffect F() {
return Weaver.callOriginal();
}

public Resource<F, Client<F>> resource() {
Resource<F, Client<F>> delegateResource = Weaver.callOriginal();
return NewrelicSecurityClientMiddleware$.MODULE$.resource(delegateResource, F());
}
}
Loading

0 comments on commit 40a3a6d

Please sign in to comment.