Skip to content
Draft
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Changed
- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다.
- 서명된 artifact token을 정확히 10개의 비어 있지 않은 payload 필드로 제한하고 HMAC 검증 후에만 claim을 해석하도록 고정했습니다. 필드 수, 빈 필드, Base64URL, epoch, UUID 및 버전 경계 회귀를 유지하며, 재현 가능한 benchmark 없이 할당량이나 처리량 개선을 주장하지 않습니다.

# Changelog

Expand Down Expand Up @@ -50,4 +51,4 @@
- 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다.

### Fixed
- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정
- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정
47 changes: 47 additions & 0 deletions scripts/test_artifact_token_parser_evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Protect artifact-token parser evidence from unsupported performance claims."""

from pathlib import Path

ARTIFACT_LINK_SERVICE = Path(
"src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkService.java"
)
BOUNDARY_TEST = Path(
"src/test/java/com/clearfolio/viewer/artifact/ArtifactTokenManualParserBoundaryTest.java"
)


def test_manual_parser_has_no_unbenchmarked_performance_claim() -> None:
"""Require benchmark evidence before claiming allocation or regex improvements."""
source = ARTIFACT_LINK_SERVICE.read_text(encoding="utf-8")

unsupported_claims = (
"불필요한 배열 할당",
"정규식 오버헤드",
"reduces array allocation",
"reduces regex overhead",
)

for claim in unsupported_claims:
assert claim not in source, (
f"unsupported artifact-token parser performance claim remains: {claim!r}"
)


def test_manual_parser_keeps_signed_boundary_regressions() -> None:
"""Require deterministic tests for every malformed signed-payload boundary."""
source = BOUNDARY_TEST.read_text(encoding="utf-8")
required_tests = (
"rejectsSignedPayloadWithOnlyNineFields",
"rejectsSignedPayloadWithElevenFields",
"rejectsSignedPayloadWithAnEmptyRequiredField",
"rejectsSignedPayloadWithMalformedBase64Url",
"rejectsSignedPayloadWithNonNumericEpochSecond",
"rejectsSignedPayloadWithOutOfRangeEpochSecond",
"rejectsSignedPayloadWithMalformedDocumentIdentifier",
"rejectsSignedPayloadWithUnsupportedVersion",
)

for test_name in required_tests:
assert f"void {test_name}()" in source, (
f"missing deterministic signed-boundary regression: {test_name}"
)
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,41 @@ public static String resolveToken(String queryToken, String authorizationHeader)
}

private ArtifactTokenClaims parseAndVerify(String token) {
String[] parts = token.split("\\.");
if (parts.length != TOKEN_FIELD_COUNT + 1) {
// Parse exactly ten non-empty payload fields before claim construction.
int lastDotIndex = token.lastIndexOf('.');
if (lastDotIndex == -1) {
throw new ArtifactTokenException(HttpStatus.UNAUTHORIZED, "artifact token invalid");
}

String payload = String.join(".", Arrays.copyOf(parts, TOKEN_FIELD_COUNT));
String payload = token.substring(0, lastDotIndex);
String signature = token.substring(lastDotIndex + 1);

String[] parts = new String[TOKEN_FIELD_COUNT];
int start = 0;
for (int i = 0; i < TOKEN_FIELD_COUNT; i++) {
if (i == TOKEN_FIELD_COUNT - 1) {
int nextDot = token.indexOf('.', start);
if (nextDot != lastDotIndex) {
throw new ArtifactTokenException(HttpStatus.UNAUTHORIZED, "artifact token invalid");
}
parts[i] = token.substring(start, lastDotIndex);
} else {
int nextDot = token.indexOf('.', start);
if (nextDot == -1 || nextDot >= lastDotIndex) {
throw new ArtifactTokenException(HttpStatus.UNAUTHORIZED, "artifact token invalid");
}
parts[i] = token.substring(start, nextDot);
start = nextDot + 1;
}
if (parts[i].isEmpty()) {
throw new ArtifactTokenException(HttpStatus.UNAUTHORIZED, "artifact token invalid");
}
}

String expectedSignature = hmac(payload);
if (!MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.US_ASCII),
parts[TOKEN_FIELD_COUNT].getBytes(StandardCharsets.US_ASCII))) {
signature.getBytes(StandardCharsets.US_ASCII))) {
throw new ArtifactTokenException(HttpStatus.UNAUTHORIZED, "artifact token invalid");
}

Expand Down Expand Up @@ -421,15 +446,19 @@ private static String nullableClean(String value) {
if (value == null) {
return null;
}
String cleaned = value.replace("\u0000", "").strip();
String cleaned = value;
if (cleaned.indexOf('\u0000') != -1) {
cleaned = cleaned.replace("\u0000", "");
}
cleaned = cleaned.strip();
return cleaned.isEmpty() ? null : cleaned;
}

private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] raw = digest.digest(bytes);
// Reused HexFormat for performance
// Use the shared formatter for deterministic lowercase hexadecimal output.
return HEX_FORMAT.formatHex(raw);
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("SHA-256 digest unavailable", ex);
Expand All @@ -441,6 +470,9 @@ private static String encode(String value) {
}

private static String decode(String value) {
if (!value.matches("^[a-zA-Z0-9_-]+$")) {
throw new IllegalArgumentException("invalid Base64URL string");
}
return new String(URL_DECODER.decode(value), StandardCharsets.UTF_8);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package com.clearfolio.viewer.artifact;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Base64;
import java.util.Set;
import java.util.UUID;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;

import com.clearfolio.viewer.api.ArtifactLinkResponse;
import com.clearfolio.viewer.auth.TenantContext;
import com.clearfolio.viewer.auth.TenantPermissions;
import com.clearfolio.viewer.model.ConversionJob;

/**
* Verifies fail-closed boundaries of the bounded artifact-token parser.
*/
class ArtifactTokenManualParserBoundaryTest {

private static final String SECRET = "test-secret";
private static final Instant NOW = Instant.parse("2026-08-06T00:00:00Z");

private InMemoryArtifactStore artifactStore;
private ArtifactLinkService service;
private UUID documentId;
private ConversionJob conversionJob;
private byte[] artifactBytes;
private String[] validPayloadFields;

@BeforeEach
void setUp() {
artifactStore = new InMemoryArtifactStore();
service = new ArtifactLinkService(
artifactStore,
SECRET,
Clock.fixed(NOW, ZoneOffset.UTC),
new FixedSecureRandom()
);
documentId = UUID.fromString("11111111-2222-3333-4444-555555555555");
conversionJob = succeededJob(documentId);
artifactBytes = new byte[] {0, 1, 2, 3};
artifactStore.putPdf(documentId, artifactBytes);
ArtifactLinkResponse link = service.createLink(conversionJob, tenantContext(), null);
String[] tokenParts = tokenFrom(link).split("\\.", -1);
assertEquals(11, tokenParts.length, "a valid token must contain ten payload fields and one signature");
validPayloadFields = Arrays.copyOf(tokenParts, 10);
}

@Test
void rejectsSignedPayloadWithOnlyNineFields() {
assertMalformedToken(signedToken(Arrays.copyOf(validPayloadFields, 9)));
}

@Test
void rejectsSignedPayloadWithElevenFields() {
String[] elevenFields = Arrays.copyOf(validPayloadFields, 11);
elevenFields[10] = encode("unexpected-field");

assertMalformedToken(signedToken(elevenFields));
}

@Test
void rejectsSignedPayloadWithAnEmptyRequiredField() {
String[] fields = validPayloadFields.clone();
fields[1] = "";

assertMalformedToken(signedToken(fields));
}

@Test
void rejectsSignedPayloadWithMalformedBase64Url() {
String[] fields = validPayloadFields.clone();
fields[1] = "*";

assertMalformedToken(signedToken(fields));
}

@Test
void rejectsSignedPayloadWithNonNumericEpochSecond() {
String[] fields = validPayloadFields.clone();
fields[8] = encode("not-a-number");

assertMalformedToken(signedToken(fields));
}

@Test
void rejectsSignedPayloadWithOutOfRangeEpochSecond() {
String[] fields = validPayloadFields.clone();
fields[8] = encode(Long.toString(Long.MAX_VALUE));

assertMalformedToken(signedToken(fields));
}

@Test
void rejectsSignedPayloadWithMalformedDocumentIdentifier() {
String[] fields = validPayloadFields.clone();
fields[4] = encode("not-a-uuid");

assertMalformedToken(signedToken(fields));
}

@Test
void rejectsSignedPayloadWithUnsupportedVersion() {
String[] fields = validPayloadFields.clone();
fields[0] = encode("v0");

assertMalformedToken(signedToken(fields));
}

private void assertMalformedToken(String token) {
ArtifactTokenException exception = assertThrows(
ArtifactTokenException.class,
() -> service.verifyReadToken(documentId, conversionJob, artifactBytes, token)
);
assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
}

private static String signedToken(String[] payloadFields) {
String payload = String.join(".", payloadFields);
return payload + "." + hmac(payload);
}

private static String tokenFrom(ArtifactLinkResponse response) {
String parameterPrefix = ArtifactLinkService.ARTIFACT_TOKEN_PARAM + "=";
return response.artifactUrl().substring(response.artifactUrl().indexOf(parameterPrefix) + parameterPrefix.length());
}

private static String hmac(String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("test HMAC unavailable", exception);
}
}

private static String encode(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}

private static TenantContext tenantContext() {
return new TenantContext(
TenantContext.DEMO_TENANT_ID,
TenantContext.DEMO_SUBJECT_ID,
Set.of(TenantPermissions.ARTIFACT_LINK_CREATE, TenantPermissions.VIEWER_READ)
);
}

private static ConversionJob succeededJob(UUID documentId) {
ConversionJob job = new ConversionJob(
documentId,
TenantContext.DEMO_TENANT_ID,
TenantContext.DEMO_SUBJECT_ID,
"report.docx",
"application/octet-stream",
"hash",
4L,
1
);
job.markSucceeded("/artifacts/" + documentId + ".pdf", "done");
return job;
}

private static final class FixedSecureRandom extends SecureRandom {
private static final long serialVersionUID = 1L;

private byte nextByte = 1;

@Override
public void nextBytes(byte[] bytes) {
for (int index = 0; index < bytes.length; index++) {
bytes[index] = nextByte++;
}
}
}
}
Loading