From 62590a3ee102642533b36c47fe467fcedc7252f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:11:24 +0900 Subject: [PATCH 1/6] test(auth): reject absent tenant context authority --- .../TenantContextAuthorityInvariantTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java 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..8a7976ef --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java @@ -0,0 +1,45 @@ +package com.clearfolio.viewer.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * Verifies that directly constructed tenant contexts cannot carry absent + * authority across internal 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(" \u0000 ", "subject-1", Set.of("job:read")) + ); + + assertEquals("tenantId is required", nullTenant.getMessage()); + assertEquals("tenantId is required", blankTenant.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", "\u0000 ", Set.of("job:read")) + ); + + assertEquals("subjectId is required", nullSubject.getMessage()); + assertEquals("subjectId is required", blankSubject.getMessage()); + } +} From f831d0f8a465258b64178681359873a4264d0eaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:14:34 +0900 Subject: [PATCH 2/6] fix(auth): require tenant and subject authority --- .../com/clearfolio/viewer/auth/TenantContext.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java index 40b79f92..bb15e857 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java @@ -65,10 +65,11 @@ 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 */ 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)); @@ -128,6 +129,14 @@ private static Set permissionsOf(String raw) { return parsed; } + private static String requireAuthority(String value, String claimName) { + String sanitized = sanitize(value); + if (sanitized == null) { + throw new IllegalArgumentException(claimName + " is required"); + } + return sanitized; + } + private static String sanitize(String value) { if (value == null) { return null; From e7aefc6391f30dc1243dc6db27fe0c76b2cd6528 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:29:12 +0900 Subject: [PATCH 3/6] test(security): reject control-corrupted tenant context authority --- .../TenantContextAuthorityInvariantTest.java | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java index 8a7976ef..0d5f38a7 100644 --- a/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java +++ b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java @@ -3,13 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; /** - * Verifies that directly constructed tenant contexts cannot carry absent - * authority across internal service boundaries. + * Verifies that directly constructed tenant contexts cannot carry absent or + * control-corrupted authority across internal service boundaries. */ class TenantContextAuthorityInvariantTest { @@ -21,13 +22,30 @@ void constructorRejectsMissingTenantAuthority() { ); IllegalArgumentException blankTenant = assertThrows( IllegalArgumentException.class, - () -> new TenantContext(" \u0000 ", "subject-1", Set.of("job:read")) + () -> 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( @@ -36,10 +54,27 @@ void constructorRejectsMissingSubjectAuthority() { ); IllegalArgumentException blankSubject = assertThrows( IllegalArgumentException.class, - () -> new TenantContext("tenant-a", "\u0000 ", Set.of("job:read")) + () -> 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()); + } + } } From ffeb1ed642a6e982660ddd8bc6e2bc09b6bd1411 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:30:15 +0900 Subject: [PATCH 4/6] test(security): reject control-corrupted tenant headers --- .../TenantContextAuthorityInvariantTest.java | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java index 0d5f38a7..204a04cc 100644 --- a/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java +++ b/src/test/java/com/clearfolio/viewer/auth/TenantContextAuthorityInvariantTest.java @@ -2,15 +2,17 @@ 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 tenant contexts cannot carry absent or - * control-corrupted authority across internal service boundaries. + * Verifies that directly constructed and header-derived tenant contexts cannot + * carry absent or control-corrupted authority across service boundaries. */ class TenantContextAuthorityInvariantTest { @@ -77,4 +79,23 @@ void constructorRejectsControlCorruptedSubjectAuthority() { 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; + } } From 45c35e34f1b81ae7a97d6abad0435d7ee23df166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:31:27 +0900 Subject: [PATCH 5/6] fix(security): reject control-corrupted tenant authority --- .../clearfolio/viewer/auth/TenantContext.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java index bb15e857..8ee002d0 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java @@ -66,6 +66,7 @@ public record TenantContext(String tenantId, String subjectId, Set permi * @param subjectId subject claim * @param permissions permission claims * @throws IllegalArgumentException when tenant or subject authority is absent + * or control-corrupted */ public TenantContext { tenantId = requireAuthority(tenantId, "tenantId"); @@ -79,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) { @@ -130,6 +131,12 @@ private static Set permissionsOf(String raw) { } 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"); @@ -138,13 +145,19 @@ private static String requireAuthority(String value, String claimName) { } private static String sanitize(String value) { - if (value == null) { + 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; + } } From 942ac58030148f10fde6789e1b705f9e583110d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:37:31 -0700 Subject: [PATCH 6/6] fix(auth): preserve valid permission entries --- src/main/java/com/clearfolio/viewer/auth/TenantContext.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java index 8ee002d0..fdcb9710 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java @@ -117,13 +117,12 @@ 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);