Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.prebid.cache.exceptions.ExpiryOutOfRangeException;
import org.prebid.cache.exceptions.InvalidUUIDException;
import org.prebid.cache.exceptions.RequestBodyDeserializeException;
import org.prebid.cache.exceptions.UnauthorizedAccessException;
import org.prebid.cache.handlers.ErrorHandler;
import org.prebid.cache.handlers.ServiceType;
import org.prebid.cache.helpers.RandomUUID;
Expand Down Expand Up @@ -110,6 +111,8 @@ public Mono<ServerResponse> save(final ServerRequest request) {
.expiry(adjustExpiry(payload.compareAndGetExpiry()))
.build())
.map(payloadWrapperTransformer())
.handle((PayloadWrapper payload, SynchronousSink<PayloadWrapper> sink) ->
validateUuidPermissions(payload, sink, isValidApiKey))
.handle(this::validateUUID)
.handle(this::validateExpiry)
.concatMap(repository::save)
Expand All @@ -135,6 +138,25 @@ public Mono<ServerResponse> save(final ServerRequest request) {
return finalizeResult(responseMono, request, timerContext);
}

private void validateUuidPermissions(PayloadWrapper payload,
SynchronousSink<PayloadWrapper> sink,
boolean isValidApiKey) {

if (payload.isExternalId() && !config.isAllowExternalUUID()) {
metricsRecorder.getRejectedExternalId().increment();
sink.error(new InvalidUUIDException("Prebid cache host forbids specifying UUID in request."));
return;
}
if (payload.isExternalId() && apiConfig.isExternalUUIDSecured() && !isValidApiKey) {
metricsRecorder.getRejectedExternalId().increment();
sink.error(new UnauthorizedAccessException(
"Prebid cache host forbids specifying UUID in request by unauthorized users."));
return;
}

sink.next(payload);
}

private boolean isValidApiKey(final ServerRequest request) {
return StringUtils.equals(request.headers().firstHeader(API_KEY_HEADER), apiConfig.getApiKey());
}
Expand All @@ -150,10 +172,6 @@ private Function<PayloadTransfer, PayloadWrapper> payloadWrapperTransformer() {
}

private void validateUUID(final PayloadWrapper payload, final SynchronousSink<PayloadWrapper> sink) {
if (payload.isExternalId() && !config.isAllowExternalUUID()) {
sink.error(new InvalidUUIDException("Prebid cache host forbids specifying UUID in request."));
return;
}
if (RandomUUID.isValidUUID(payload.getId())) {
sink.next(payload);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/org/prebid/cache/metrics/MeasurementTag.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum MeasurementTag {
XML("pbc.${prefix}.xml"),
ERROR_SECONDARY_WRITE("pbc.err.secondaryWrite"),
ERROR_EXISTING_ID("pbc.err.existingId"),
ERROR_REJECTED_EXTERNAL_ID("pbc.err.rejectedExternalId"),
PROXY_SUCCESS("pbc.proxy.success"),
PROXY_FAILURE("pbc.proxy.failure");

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/prebid/cache/metrics/MetricsRecorder.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public Counter getProxyFailure() {
return meterRegistry.counter(MeasurementTag.PROXY_FAILURE.getTag());
}

public Counter getRejectedExternalId() {
return meterRegistry.counter(MeasurementTag.ERROR_REJECTED_EXTERNAL_ID.getTag());
}

private Counter meterForTag(final String prefix, final MeasurementTag measurementTag) {
return meterRegistry.counter(measurementTag.getTag().replaceAll(PREFIX_PLACEHOLDER, prefix));
}
Expand Down
1 change: 1 addition & 0 deletions src/main/java/org/prebid/cache/routers/ApiConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class ApiConfig {
private String cachePath;

private boolean cacheWriteSecured;
private boolean externalUUIDSecured;

@NotEmpty
private String storagePath;
Expand Down
55 changes: 55 additions & 0 deletions src/test/java/org/prebid/cache/handlers/PostCacheHandlerTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,59 @@ void testAuthorization() {
.expectComplete()
.verify();
}

@Test
void testUuidAuthorizationWithoutValidApiKey() {
given(apiConfig.isExternalUUIDSecured()).willReturn(true);
given(apiConfig.getApiKey()).willReturn("api-key");

final var handler = new PostCacheHandler(
repository,
cacheConfig,
metricsRecorder,
builder,
webClientCircuitBreaker,
samplingRate,
apiConfig);

final var request = Mono.just(RequestObject.of(Collections.singletonList(PAYLOAD_TRANSFER)));
final var requestMono = MockServerRequest.builder()
.method(HttpMethod.POST)
.header(CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE)
.body(request);

StepVerifier.create(handler.save(requestMono))
.consumeNextWith(serverResponse -> assertEquals(401, serverResponse.statusCode().value()))
.expectComplete()
.verify();
}


@Test
void testUuidAuthorizationWithValidApiKey() {
given(apiConfig.isExternalUUIDSecured()).willReturn(true);
given(apiConfig.getApiKey()).willReturn("api-key");
given(repository.save(PAYLOAD_WRAPPER)).willReturn(Mono.just(PAYLOAD_WRAPPER));

final var handler = new PostCacheHandler(
repository,
cacheConfig,
metricsRecorder,
builder,
webClientCircuitBreaker,
samplingRate,
apiConfig);

final var request = Mono.just(RequestObject.of(Collections.singletonList(PAYLOAD_TRANSFER)));
final var requestMono = MockServerRequest.builder()
.method(HttpMethod.POST)
.header(CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE)
.header("x-pbc-api-key", "api-key")
.body(request);

StepVerifier.create(handler.save(requestMono))
.consumeNextWith(serverResponse -> assertEquals(200, serverResponse.statusCode().value()))
.expectComplete()
.verify();
}
}
189 changes: 185 additions & 4 deletions src/test/kotlin/org/prebid/cache/functional/AuthenticationCacheSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.kotest.assertions.throwables.shouldThrowExactly
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.beEmpty
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.http.contentType
import org.prebid.cache.functional.BaseSpec.Companion.prebidCacheConfig
Expand All @@ -13,6 +14,7 @@ import org.prebid.cache.functional.model.request.RequestObject
import org.prebid.cache.functional.model.request.TransferValue
import org.prebid.cache.functional.service.ApiException
import org.prebid.cache.functional.util.getRandomString
import org.prebid.cache.functional.util.getRandomUuid
import org.springframework.http.HttpStatus.UNAUTHORIZED

class AuthenticationCacheSpec : ShouldSpec({
Expand Down Expand Up @@ -102,7 +104,7 @@ class AuthenticationCacheSpec : ShouldSpec({
// when: POST cache endpoint is called
val exception = shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject, apiKey = null) }

// then: Bad Request exception is thrown
// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody should beEmpty()
Expand All @@ -126,7 +128,7 @@ class AuthenticationCacheSpec : ShouldSpec({
val exception =
shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject, apiKey = "") }

// then: Bad Request exception is thrown
// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody should beEmpty()
Expand All @@ -150,7 +152,7 @@ class AuthenticationCacheSpec : ShouldSpec({
val exception =
shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject, apiKey = getRandomString()) }

// then: Bad Request exception is thrown
// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody should beEmpty()
Expand All @@ -175,10 +177,189 @@ class AuthenticationCacheSpec : ShouldSpec({
val exception =
shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject, apiKey = prebidApiKey.uppercase()) }

// then: Bad Request exception is thrown
// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody should beEmpty()
}
}

should("should save JSON transfer value with proper api-key in header when cache-write-secured and external-uuid-secured are enabled") {
// given: Prebid Cache with external-uuid-secured=true property
val prebidApiKey = getRandomString()
val prebidCacheApi = BaseSpec.getPrebidCacheApi(
prebidCacheConfig.getBaseRedisConfig(
allowExternalUuid = true,
cacheWriteSecured = true,
externalUuidSecured = true,
apiKey = prebidApiKey
)
)

// and: Request object with JSON transfer value
val requestObject = RequestObject.getDefaultJsonRequestObject()
val requestTransferValue = objectMapper.readValue(requestObject.puts[0].value, TransferValue::class.java)

// when: POST cache endpoint is called
val postResponse = prebidCacheApi.postCache(requestObject, apiKey = prebidApiKey)

// when: GET cache endpoint is called
val getCacheResponse = BaseSpec.getPrebidCacheApi().getCache(postResponse.responses[0].uuid)

// then: response content type is the same as request object type
getCacheResponse.contentType()?.contentType shouldBe "application"
getCacheResponse.contentType()?.contentSubtype shouldBe requestObject.puts[0].type.getValue()

// and: transfer value is returned
val responseTransferValue = objectMapper.readValue(getCacheResponse.bodyAsText(), TransferValue::class.java)

assertSoftly {
responseTransferValue.adm shouldBe requestTransferValue.adm
responseTransferValue.width shouldBe requestTransferValue.width
responseTransferValue.height shouldBe requestTransferValue.height
}
}

should("should save JSON transfer value with proper api-key in header when cache-write-secured disabled and external-uuid-secured enabled") {
// given: Prebid Cache with external-uuid-secured=true property
val prebidApiKey = getRandomString()
val prebidCacheApi = BaseSpec.getPrebidCacheApi(
prebidCacheConfig.getBaseRedisConfig(
allowExternalUuid = true,
cacheWriteSecured = false,
externalUuidSecured = true,
apiKey = prebidApiKey
)
)

// and: Request object with JSON transfer value
val requestObject = RequestObject.getDefaultJsonRequestObject()
val requestTransferValue = objectMapper.readValue(requestObject.puts[0].value, TransferValue::class.java)

// when: POST cache endpoint is called
val postResponse = prebidCacheApi.postCache(requestObject, apiKey = prebidApiKey)

// when: GET cache endpoint is called
val getCacheResponse = BaseSpec.getPrebidCacheApi().getCache(postResponse.responses[0].uuid)

// then: response content type is the same as request object type
getCacheResponse.contentType()?.contentType shouldBe "application"
getCacheResponse.contentType()?.contentSubtype shouldBe requestObject.puts[0].type.getValue()

// and: transfer value is returned
val responseTransferValue = objectMapper.readValue(getCacheResponse.bodyAsText(), TransferValue::class.java)

assertSoftly {
responseTransferValue.adm shouldBe requestTransferValue.adm
responseTransferValue.width shouldBe requestTransferValue.width
responseTransferValue.height shouldBe requestTransferValue.height
}
}

should("should save JSON transfer value with proper api-key in header when cache-write-secured and external-uuid-secured are disabled") {
// given: Prebid Cache with external-uuid-secured=false property
val prebidCacheApi = BaseSpec.getPrebidCacheApi(
prebidCacheConfig.getBaseRedisConfig(
allowExternalUuid = true,
cacheWriteSecured = false,
externalUuidSecured = false,
apiKey = getRandomString()
)
)

// and: Request object with JSON transfer value
val requestObject = RequestObject.getDefaultJsonRequestObject()
val requestTransferValue = objectMapper.readValue(requestObject.puts[0].value, TransferValue::class.java)

// when: POST cache endpoint is called
val postResponse = prebidCacheApi.postCache(requestObject)

// when: GET cache endpoint is called
val getCacheResponse = BaseSpec.getPrebidCacheApi().getCache(postResponse.responses[0].uuid)

// then: response content type is the same as request object type
getCacheResponse.contentType()?.contentType shouldBe "application"
getCacheResponse.contentType()?.contentSubtype shouldBe requestObject.puts[0].type.getValue()

// and: transfer value is returned
val responseTransferValue = objectMapper.readValue(getCacheResponse.bodyAsText(), TransferValue::class.java)

assertSoftly {
responseTransferValue.adm shouldBe requestTransferValue.adm
responseTransferValue.width shouldBe requestTransferValue.width
responseTransferValue.height shouldBe requestTransferValue.height
}
}

should("throw an exception when api key is missing for request cache_write_secured=true and external_uuid_Secured=true") {
// given: Prebid Cache with external-uuid-secured=true property
val prebidCacheApi = BaseSpec.getPrebidCacheApi(
prebidCacheConfig.getBaseRedisConfig(
allowExternalUuid = true,
cacheWriteSecured = true,
externalUuidSecured = true,
apiKey = getRandomString()
)
)

// and: Request object with set payload transfer key
val requestObject = RequestObject.getDefaultJsonRequestObject().apply { puts[0].key = getRandomUuid() }

// when: POST cache endpoint is called
val exception = shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject) }

// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody should beEmpty()
}
}

should("throw an exception when api key is missing for request and external_uuid_Secured=true") {
// given: Prebid Cache with external-uuid-secured=true property
val prebidCacheApi = BaseSpec.getPrebidCacheApi(
prebidCacheConfig.getBaseRedisConfig(
allowExternalUuid = true,
cacheWriteSecured = false,
externalUuidSecured = true,
apiKey = getRandomString()
)
)

// and: Request object with set payload transfer key
val requestObject = RequestObject.getDefaultJsonRequestObject().apply { puts[0].key = getRandomUuid() }

// when: POST cache endpoint is called
val exception = shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject) }

// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody shouldContain "\"message\":\"Prebid cache host forbids specifying UUID in request by unauthorized users.\""
}
}

should("throw an exception when api key is mismatched for request and external-uuid-secured=true") {
// given: Prebid Cache with external-uuid-secured=true property
val prebidCacheApi = BaseSpec.getPrebidCacheApi(
prebidCacheConfig.getBaseRedisConfig(
allowExternalUuid = true,
cacheWriteSecured = false,
externalUuidSecured = true,
apiKey = getRandomString()
)
)

// and: Request object with set payload transfer key
val requestObject = RequestObject.getDefaultJsonRequestObject().apply { puts[0].key = getRandomUuid() }

// when: POST cache endpoint is called
val exception = shouldThrowExactly<ApiException> { prebidCacheApi.postCache(requestObject, apiKey = getRandomString()) }

// then: Unauthorized exception is thrown
assertSoftly {
exception.statusCode shouldBe UNAUTHORIZED.value()
exception.responseBody shouldContain "\"message\":\"Prebid cache host forbids specifying UUID in request by unauthorized users.\""
}
}
})
Loading