diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java index 40b79f92..fdcb9710 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java @@ -65,10 +65,12 @@ public record TenantContext(String tenantId, String subjectId, Set permi * @param tenantId tenant claim * @param subjectId subject claim * @param permissions permission claims + * @throws IllegalArgumentException when tenant or subject authority is absent + * or control-corrupted */ public TenantContext { - tenantId = sanitize(tenantId); - subjectId = sanitize(subjectId); + tenantId = requireAuthority(tenantId, "tenantId"); + subjectId = requireAuthority(subjectId, "subjectId"); permissions = permissions == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(permissions)); @@ -78,7 +80,7 @@ public record TenantContext(String tenantId, String subjectId, Set permi * Builds a tenant context from request headers. * * @param headers request headers - * @return tenant context when required claims are present + * @return tenant context when required claims are present and control-safe */ public static Optional fromHeaders(HttpHeaders headers) { if (headers == null) { @@ -115,27 +117,46 @@ public String canonicalPermissions() { } private static Set permissionsOf(String raw) { - String normalized = sanitize(raw); - if (normalized == null) { + if (raw == null) { return Set.of(); } LinkedHashSet parsed = new LinkedHashSet<>(); - Arrays.stream(normalized.split(",")) + Arrays.stream(raw.split(",")) .map(TenantContext::sanitize) .filter(value -> value != null) .forEach(parsed::add); return parsed; } - private static String sanitize(String value) { + private static String requireAuthority(String value, String claimName) { if (value == null) { + throw new IllegalArgumentException(claimName + " is required"); + } + if (value.codePoints().anyMatch(TenantContext::isDisallowedClaimCharacter)) { + throw new IllegalArgumentException(claimName + " must not contain control characters"); + } + String sanitized = sanitize(value); + if (sanitized == null) { + throw new IllegalArgumentException(claimName + " is required"); + } + return sanitized; + } + + private static String sanitize(String value) { + if (value == null + || value.codePoints().anyMatch(TenantContext::isDisallowedClaimCharacter)) { return null; } - String sanitized = value - .replace("\u0000", "") - .strip(); + String sanitized = value.strip(); return sanitized.isEmpty() ? null : sanitized; } + + private static boolean isDisallowedClaimCharacter(int codePoint) { + int characterType = Character.getType(codePoint); + return Character.isISOControl(codePoint) + || characterType == Character.LINE_SEPARATOR + || characterType == Character.PARAGRAPH_SEPARATOR; + } } diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java new file mode 100644 index 00000000..204a04cc --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java @@ -0,0 +1,101 @@ +package com.clearfolio.viewer.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; + +/** + * Verifies that directly constructed and header-derived tenant contexts cannot + * carry absent or control-corrupted authority across service boundaries. + */ +class TenantContextAuthorityInvariantTest { + + @Test + void constructorRejectsMissingTenantAuthority() { + IllegalArgumentException nullTenant = assertThrows( + IllegalArgumentException.class, + () -> new TenantContext(null, "subject-1", Set.of("job:read")) + ); + IllegalArgumentException blankTenant = assertThrows( + IllegalArgumentException.class, + () -> new TenantContext(" ", "subject-1", Set.of("job:read")) + ); + + assertEquals("tenantId is required", nullTenant.getMessage()); + assertEquals("tenantId is required", blankTenant.getMessage()); + } + + @Test + void constructorRejectsControlCorruptedTenantAuthority() { + for (String tenantId : List.of( + "tenant\u0000-a", + "tenant\n-a", + "tenant\u001B-a", + "tenant\u2028-a" + )) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TenantContext(tenantId, "subject-1", Set.of("job:read")) + ); + + assertEquals("tenantId must not contain control characters", exception.getMessage()); + } + } + + @Test + void constructorRejectsMissingSubjectAuthority() { + IllegalArgumentException nullSubject = assertThrows( + IllegalArgumentException.class, + () -> new TenantContext("tenant-a", null, Set.of("job:read")) + ); + IllegalArgumentException blankSubject = assertThrows( + IllegalArgumentException.class, + () -> new TenantContext("tenant-a", " ", Set.of("job:read")) + ); + + assertEquals("subjectId is required", nullSubject.getMessage()); + assertEquals("subjectId is required", blankSubject.getMessage()); + } + + @Test + void constructorRejectsControlCorruptedSubjectAuthority() { + for (String subjectId : List.of( + "subject\u0000-a", + "subject\t-a", + "subject\u007F-a", + "subject\u2029-a" + )) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TenantContext("tenant-a", subjectId, Set.of("job:read")) + ); + + assertEquals("subjectId must not contain control characters", exception.getMessage()); + } + } + + @Test + void headerParsingRejectsControlCorruptedClaimsAndPermissions() { + HttpHeaders controlTenant = headers("tenant\n-a", "subject-a", "job:read"); + HttpHeaders controlSubject = headers("tenant-a", "subject\u2028-a", "job:read"); + HttpHeaders controlPermission = headers("tenant-a", "subject-a", "job:\u0000read"); + + assertTrue(TenantContext.fromHeaders(controlTenant).isEmpty()); + assertTrue(TenantContext.fromHeaders(controlSubject).isEmpty()); + assertTrue(TenantContext.fromHeaders(controlPermission).isEmpty()); + } + + private static HttpHeaders headers(String tenantId, String subjectId, String permissions) { + HttpHeaders headers = new HttpHeaders(); + headers.set(TenantContext.TENANT_ID_HEADER, tenantId); + headers.set(TenantContext.SUBJECT_ID_HEADER, subjectId); + headers.set(TenantContext.PERMISSIONS_HEADER, permissions); + return headers; + } +}