Skip to content

Commit

Permalink
Add support for Do Not Track cookie (close #400)
Browse files Browse the repository at this point in the history
This is a server-side option to disable tracking once events are received from a
tracker. These requests are not sank and instead are silently discarded.
Trackers also enable a similar mechanism[1] that will not pass events to the
collector.
This is likely a better approach, but having it in the collector is another
prevention mechanism.

1 - https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/web-tracker/tracker-setup/initialization-options/#respecting-do-not-track
  • Loading branch information
peel committed Dec 5, 2023
1 parent fff86e1 commit 3106307
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ object Run {
appInfo
)
httpServer = HttpServer.build[F](
new Routes[F](config.enableDefaultRedirect, config.rootResponse.enabled, config.crossDomain.enabled, collectorService).value,
new Routes[F](
config.enableDefaultRedirect,
config.rootResponse.enabled,
config.crossDomain.enabled,
collectorService
).value,
if (config.ssl.enable) config.ssl.port else config.port,
config.ssl.enable,
config.networking
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,24 @@ class Service[F[_]: Sync](
): F[Response[F]] =
for {
body <- body
redirect = path.startsWith("/r/")
hostname = extractHostname(request)
userAgent = extractHeader(request, "User-Agent")
refererUri = extractHeader(request, "Referer")
spAnonymous = extractHeader(request, "SP-Anonymous")
ip = extractIp(request, spAnonymous)
queryString = Some(request.queryString)
cookie = extractCookie(request)
nuidOpt = networkUserId(request, cookie, spAnonymous)
nuid = nuidOpt.getOrElse(UUID.randomUUID().toString)
redirect = path.startsWith("/r/")
hostname = extractHostname(request)
userAgent = extractHeader(request, "User-Agent")
refererUri = extractHeader(request, "Referer")
spAnonymous = extractHeader(request, "SP-Anonymous")
ip = extractIp(request, spAnonymous)
queryString = Some(request.queryString)
cookie = extractCookie(request)
doNotTrack = checkDoNotTrackCookie(request)
alreadyBouncing = queryString.contains(config.cookieBounce.name)
nuidOpt = networkUserId(request, cookie, spAnonymous)
nuid = nuidOpt.getOrElse {
if (alreadyBouncing) config.cookieBounce.fallbackNetworkUserId
else UUID.randomUUID().toString
}
shouldBounce = config.cookieBounce.enabled && nuidOpt.isEmpty && !alreadyBouncing &&
pixelExpected && !redirect

(ipAddress, partitionKey) = ipAndPartitionKey(ip, config.streams.useIpAddressAsPartitionKey)
event = buildEvent(
queryString,
Expand Down Expand Up @@ -113,13 +121,14 @@ class Service[F[_]: Sync](
accessControlAllowOriginHeader(request).some,
`Access-Control-Allow-Credentials`().toRaw1.some
).flatten
responseHeaders = Headers(headerList)
_ <- sinkEvent(event, partitionKey)
responseHeaders = Headers(headerList ++ bounceLocationHeaders(config.cookieBounce, shouldBounce, request))
_ <- if (!doNotTrack && !shouldBounce) sinkEvent(event, partitionKey) else Sync[F].unit
resp = buildHttpResponse(
queryParams = request.uri.query.params,
headers = responseHeaders,
redirect = redirect,
pixelExpected = pixelExpected
pixelExpected = pixelExpected,
shouldBounce = shouldBounce
)
} yield resp

Expand Down Expand Up @@ -181,6 +190,13 @@ class Service[F[_]: Sync](
def extractCookie(req: Request[F]): Option[RequestCookie] =
req.cookies.find(_.name == config.cookie.name)

def checkDoNotTrackCookie(req: Request[F]): Boolean =
config.doNotTrackCookie.enabled && req
.cookies
.find(_.name == config.doNotTrackCookie.name)
.map(cookie => config.doNotTrackCookie.value.r.matches(cookie.content))
.getOrElse(false)

def extractHostname(req: Request[F]): Option[String] =
req.uri.authority.map(_.host.renderString) // Hostname is extracted like this in Akka-Http as well

Expand Down Expand Up @@ -226,23 +242,25 @@ class Service[F[_]: Sync](
queryParams: Map[String, String],
headers: Headers,
redirect: Boolean,
pixelExpected: Boolean
pixelExpected: Boolean,
shouldBounce: Boolean
): Response[F] =
if (redirect)
buildRedirectHttpResponse(queryParams, headers)
else
buildUsualHttpResponse(pixelExpected, headers)
buildUsualHttpResponse(pixelExpected, shouldBounce, headers)

/** Builds the appropriate http response when not dealing with click redirects. */
def buildUsualHttpResponse(pixelExpected: Boolean, headers: Headers): Response[F] =
pixelExpected match {
case true =>
def buildUsualHttpResponse(pixelExpected: Boolean, shouldBounce: Boolean, headers: Headers): Response[F] =
(pixelExpected, shouldBounce) match {
case (true, true) => Response[F](status = Found, headers = headers)
case (true, false) =>
Response[F](
headers = headers.put(`Content-Type`(MediaType.image.gif)),
body = pixelStream
)
// See https://github.com/snowplow/snowplow-javascript-tracker/issues/482
case false =>
case _ =>
Response[F](
status = Ok,
headers = headers,
Expand Down Expand Up @@ -439,4 +457,25 @@ class Service[F[_]: Sync](
case None => request.uri.query.params.get("nuid").orElse(requestCookie.map(_.content))
}

/**
* Builds a location header redirecting to itself to check if third-party cookies are blocked.
*
* @param request
* @param shouldBounce
* @return http optional location header
*/
def bounceLocationHeaders(cfg: Config.CookieBounce, shouldBounce: Boolean, request: Request[F]): Option[Header.Raw] =
if (shouldBounce) Some {
val forwardedScheme = for {
headerName <- cfg.forwardedProtocolHeader.map(CIString(_))
headerValue <- request.headers.get(headerName).flatMap(_.map(_.value).toList.headOption)
maybeScheme <- if (Set("http", "https").contains(headerValue)) Some(headerValue) else None
scheme <- Uri.Scheme.fromString(maybeScheme).toOption
} yield scheme
val redirectUri =
request.uri.withQueryParam(cfg.name, "true").copy(scheme = forwardedScheme.orElse(request.uri.scheme))

`Location`(redirectUri).toRaw1
} else None

}
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@ class ServiceSpec extends Specification {
queryParams = Map("u" -> "https://example1.com/12"),
headers = testHeaders,
redirect = true,
pixelExpected = true
pixelExpected = true,
shouldBounce = false
)
res.status shouldEqual Status.Found
res.headers shouldEqual testHeaders.put(Location(Uri.unsafeFromString("https://example1.com/12")))
Expand All @@ -501,18 +502,31 @@ class ServiceSpec extends Specification {
queryParams = Map.empty,
headers = testHeaders,
redirect = false,
pixelExpected = true
pixelExpected = true,
shouldBounce = false
)
res.status shouldEqual Status.Ok
res.headers shouldEqual testHeaders.put(`Content-Type`(MediaType.image.gif))
res.body.compile.toList.unsafeRunSync().toArray shouldEqual Service.pixel
}
"return 302 Found if expecting tracking pixel and cookie shouldBounce is performed" in {
val res = service.buildHttpResponse(
queryParams = Map.empty,
headers = testHeaders,
redirect = false,
pixelExpected = true,
shouldBounce = true
)
res.status shouldEqual Status.Found
res.headers shouldEqual testHeaders
}
"send back ok otherwise" in {
val res = service.buildHttpResponse(
queryParams = Map.empty,
headers = testHeaders,
redirect = false,
pixelExpected = false
pixelExpected = false,
shouldBounce = false
)
res.status shouldEqual Status.Ok
res.headers shouldEqual testHeaders
Expand All @@ -524,7 +538,8 @@ class ServiceSpec extends Specification {
"send back a gif if pixelExpected is true" in {
val res = service.buildUsualHttpResponse(
headers = testHeaders,
pixelExpected = true
pixelExpected = true,
shouldBounce = false
)
res.status shouldEqual Status.Ok
res.headers shouldEqual testHeaders.put(`Content-Type`(MediaType.image.gif))
Expand All @@ -533,7 +548,8 @@ class ServiceSpec extends Specification {
"send back ok otherwise" in {
val res = service.buildUsualHttpResponse(
headers = testHeaders,
pixelExpected = false
pixelExpected = false,
shouldBounce = false
)
res.status shouldEqual Status.Ok
res.headers shouldEqual testHeaders
Expand Down Expand Up @@ -1024,5 +1040,56 @@ class ServiceSpec extends Specification {
body must contain("<cross-domain-policy>")
body must endWith("</cross-domain-policy>")
}

"checkDoNotTrackCookie" should {
"be disabled when value does not match regex" in {
val cookieName = "do-not-track"
val expected = "lorem-1p5uM"
val request = Request[IO](
headers = Headers(
Cookie(RequestCookie(cookieName, expected))
)
)
val service = new Service(
config =
TestUtils.testConfig.copy(doNotTrackCookie = Config.DoNotTrackCookie(true, cookieName, "^snowplow-(.*)$")),
sinks = Sinks(new TestSink, new TestSink),
appInfo = TestUtils.appInfo
)
service.checkDoNotTrackCookie(request) should beFalse
}
"be disabled when name does not match config" in {
val cookieName = "do-not-track"
val expected = "lorem-1p5uM"
val request = Request[IO](
headers = Headers(
Cookie(RequestCookie(cookieName, expected))
)
)
val service = new Service(
config = TestUtils
.testConfig
.copy(doNotTrackCookie = Config.DoNotTrackCookie(true, s"snowplow-$cookieName", "^(.*)$")),
sinks = Sinks(new TestSink, new TestSink),
appInfo = TestUtils.appInfo
)
service.checkDoNotTrackCookie(request) should beFalse
}
"match cookie against a regex when it exists" in {
val cookieName = "do-not-track"
val expected = "lorem-1p5uM"
val request = Request[IO](
headers = Headers(
Cookie(RequestCookie(cookieName, expected))
)
)
val service = new Service(
config = TestUtils.testConfig.copy(doNotTrackCookie = Config.DoNotTrackCookie(true, cookieName, "^(.*)$")),
sinks = Sinks(new TestSink, new TestSink),
appInfo = TestUtils.appInfo
)
service.checkDoNotTrackCookie(request) should beTrue
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ class DoNotTrackCookieSpec extends Specification with Localstack with CatsEffect
expected.forall(cookie => headers.exists(_.contains(cookie))) must beTrue
}
}.unsafeRunSync()
}.pendingUntilFixed("Remove when 'do not track cookie' feature is implemented")
}

"track events that have a cookie whose name and value match doNotTrackCookie config if disabled" in {
val testName = "doNotTrackCookie-disabled"
val streamGood = s"$testName-raw"
Expand Down

0 comments on commit 3106307

Please sign in to comment.