Skip to content
Open
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 @@ -110,6 +110,12 @@ public CompletableFuture<RevokeResponse> revoke(RevokeRequest request) {
Objects.requireNonNull(request, "request");
requireValid("Revoke request", AepValidation.revokeRequest(request));
return commandContext(AepCommand.REVOKE).thenCompose(context -> {
if (request.grantType() != null
&& !context.inspection.document().commands().grantTypes().contains(request.grantType())) {
return failed(
"grant_type_not_advertised",
"AEP Service does not advertise Grant Type " + request.grantType());
}
requirePerCredentialRevoke(context.inspection.document(), request);
String discriminator = request.credentialId() == null ? request.grantType() : request.credentialId();
String key = idempotencyKey(context, AepCommand.REVOKE, discriminator == null ? "all" : discriminator);
Expand Down Expand Up @@ -455,7 +461,7 @@ private static void requirePerCredentialRevoke(InspectDocument document, RevokeR
}

private String idempotencyKey(CommandContext context, AepCommand command, String discriminator) {
return requireNonBlank(
return AgentHttp.requireFieldValue(
agent.idempotencyKeyProvider.keyFor(
context.inspection.document().service().did(), command, discriminator),
"idempotency key");
Expand Down
80 changes: 64 additions & 16 deletions aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,29 @@ static URI origin(URI value, boolean allowInsecureLoopback) {
}

static boolean sameOrigin(URI left, URI right) {
return left != null
&& right != null
&& left.getScheme() != null
&& right.getScheme() != null
&& left.getHost() != null
&& right.getHost() != null
&& left.getScheme().equalsIgnoreCase(right.getScheme())
&& IDN.toASCII(left.getHost()).equalsIgnoreCase(IDN.toASCII(right.getHost()))
&& effectivePort(left) == effectivePort(right);
if (left == null
|| right == null
|| left.getScheme() == null
|| right.getScheme() == null
|| left.getHost() == null
|| right.getHost() == null
|| !left.getScheme().equalsIgnoreCase(right.getScheme())
|| effectivePort(left) != effectivePort(right)) {
return false;
}
// A redirect Location is attacker-controlled, and IDN.toASCII rejects a malformed label by
// throwing. An unparseable host is not the same origin; it is not an error to propagate.
String leftHost = asciiHost(left.getHost());
String rightHost = asciiHost(right.getHost());
return leftHost != null && leftHost.equalsIgnoreCase(rightHost);
}

private static String asciiHost(String host) {
try {
return IDN.toASCII(host);
} catch (IllegalArgumentException exception) {
return null;
}
}

static boolean validInspectTarget(URI origin, URI target) {
Expand Down Expand Up @@ -95,16 +109,28 @@ static AepAgentException commandError(AepHttpTransport.Response response) {
}
}

/**
* Resolves the freshness lifetime a cached document may be reused for.
*
* Cache-Control directives are an unordered set, so a response is scanned for the directives
* that forbid reuse before the one that permits it. Returning on the first directive instead
* let `max-age=600, no-cache` store a document as fresh for ten minutes, where CORE-DISC-014
* requires `no-cache` to force revalidation whichever order the two arrive in.
*/
static Instant expiresAt(AepHttpTransport.Response response, Instant now, Duration fallback) {
String cacheControl = String.join(",", headerValues(response, "Cache-Control"));
for (String directive : cacheControl.split(",")) {
String value = directive.trim().toLowerCase(Locale.ROOT);
if (CACHE_NO_STORE.equals(value) || CACHE_NO_CACHE.equals(value)) {
return now;
List<String> directives = new ArrayList<>();
for (String value : headerValues(response, "Cache-Control")) {
for (String directive : value.split(",")) {
directives.add(directive.trim().toLowerCase(Locale.ROOT));
}
if (value.startsWith("max-age=")) {
}
if (directives.contains(CACHE_NO_STORE) || directives.contains(CACHE_NO_CACHE)) {
return now;
}
for (String directive : directives) {
if (directive.startsWith("max-age=")) {
try {
long seconds = Long.parseLong(value.substring("max-age=".length()));
long seconds = Long.parseLong(directive.substring("max-age=".length()));
return safeAdd(now, Duration.ofSeconds(Math.max(0, seconds)), fallback);
} catch (ArithmeticException | NumberFormatException exception) {
return safeAdd(now, fallback, Duration.ZERO);
Expand All @@ -114,6 +140,28 @@ static Instant expiresAt(AepHttpTransport.Response response, Instant now, Durati
return safeAdd(now, fallback, Duration.ZERO);
}

/**
* Checks a value the SDK is about to send as an HTTP field value.
*
* The idempotency key comes from an application-supplied provider and is written straight into
* a request header. A value carrying a control character or a line break is not a field value
* at all, and surrounding whitespace is stripped in transit, so the Service would record a
* different key than the Agent did.
*/
static String requireFieldValue(String value, String name) {
if (value == null || value.isBlank() || !value.equals(value.strip())) {
throw new AepAgentException(
"invalid_configuration", name + " must be a non-empty value without surrounding whitespace");
}
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (character < 0x20 || character == 0x7f) {
throw new AepAgentException("invalid_configuration", name + " must not contain control characters");
}
}
return value;
}

static boolean isNoStore(AepHttpTransport.Response response) {
return headerValues(response, "Cache-Control").stream()
.flatMap(value -> java.util.Arrays.stream(value.split(",")))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,24 @@ private URI endpoint(String path, String identityId) {

private String idempotencyKey() {
String value = idempotencyKeys.get();
if (value == null || value.isBlank()) {
if (value == null || value.isBlank() || !value.equals(value.strip()) || hasControlCharacter(value)) {
throw new AepAgentException(
"platform_idempotency_key_invalid", "AEP Platform idempotency key must not be blank");
"platform_idempotency_key_invalid",
"AEP Platform idempotency key must be a field value without surrounding whitespace");
}
return value;
}

private static boolean hasControlCharacter(String value) {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (character < 0x20 || character == 0x7f) {
return true;
}
}
return false;
}

private static boolean ownedHeader(String name) {
return name != null
&& ("Accept".equalsIgnoreCase(name)
Expand Down
Loading