Skip to content

Commit ef46257

Browse files
authored
Partial validate of Docker-Content-Digest when downloading or getting blob (#299)
2 parents 1dcce09 + 7d83256 commit ef46257

10 files changed

Lines changed: 213 additions & 38 deletions

src/main/java/land/oras/LayoutRef.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public SupportedAlgorithm getAlgorithm() {
130130
return SupportedAlgorithm.getDefault();
131131
}
132132
// See https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests
133-
else if (SupportedAlgorithm.matchPattern(tag)) {
133+
else if (SupportedAlgorithm.isSupported(tag)) {
134134
return SupportedAlgorithm.fromDigest(tag);
135135
}
136136

@@ -145,7 +145,7 @@ public boolean isValidDigest() {
145145
if (tag == null) {
146146
return false;
147147
}
148-
return SupportedAlgorithm.matchPattern(tag);
148+
return SupportedAlgorithm.isSupported(tag);
149149
}
150150

151151
@Override

src/main/java/land/oras/OCILayout.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ private Path getBlobPath(LayoutRef ref) {
432432
if (ref.getTag() == null) {
433433
throw new OrasException("Tag is required to get blob from layout");
434434
}
435-
boolean isDigest = SupportedAlgorithm.matchPattern(ref.getTag());
435+
boolean isDigest = SupportedAlgorithm.isSupported(ref.getTag());
436436
if (isDigest) {
437437
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(ref.getTag());
438438
return getBlobPath().resolve(algorithm.getPrefix()).resolve(SupportedAlgorithm.getDigest(ref.getTag()));
@@ -552,7 +552,7 @@ private void ensureDigest(LayoutRef ref, Path path) {
552552
if (ref.getTag() == null) {
553553
throw new OrasException("Missing ref");
554554
}
555-
if (!SupportedAlgorithm.matchPattern(ref.getTag())) {
555+
if (!SupportedAlgorithm.isSupported(ref.getTag())) {
556556
throw new OrasException("Unsupported digest: %s".formatted(ref.getTag()));
557557
}
558558
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(ref.getTag());

src/main/java/land/oras/Registry.java

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -484,11 +484,21 @@ private HttpClient.ResponseWrapper<String> headBlob(ContainerRef containerRef) {
484484
*/
485485
@Override
486486
public byte[] getBlob(ContainerRef containerRef) {
487-
try (InputStream is = fetchBlob(containerRef)) {
488-
return ensureDigest(containerRef, is.readAllBytes());
489-
} catch (IOException e) {
490-
throw new OrasException("Failed to get blob", e);
487+
if (!hasBlob(containerRef)) {
488+
throw new OrasException(new HttpClient.ResponseWrapper<>("", 404, Map.of()));
491489
}
490+
URI uri = URI.create(
491+
"%s://%s".formatted(getScheme(), containerRef.forRegistry(this).getBlobsPath(this)));
492+
HttpClient.ResponseWrapper<String> response = client.get(
493+
uri,
494+
Map.of(Const.ACCEPT_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE),
495+
Scopes.of(this, containerRef),
496+
authProvider);
497+
logResponse(response);
498+
handleError(response);
499+
byte[] data = response.response().getBytes(StandardCharsets.UTF_8);
500+
validateDockerContentDigest(response, data);
501+
return data;
492502
}
493503

494504
@Override
@@ -506,6 +516,7 @@ public void fetchBlob(ContainerRef containerRef, Path path) {
506516
authProvider);
507517
logResponse(response);
508518
handleError(response);
519+
validateDockerContentDigest(response, path);
509520
}
510521

511522
@Override
@@ -522,6 +533,7 @@ public InputStream fetchBlob(ContainerRef containerRef) {
522533
authProvider);
523534
logResponse(response);
524535
handleError(response);
536+
validateDockerContentDigest(response);
525537
return response.response();
526538
}
527539

@@ -530,8 +542,8 @@ public Descriptor fetchBlobDescriptor(ContainerRef containerRef) {
530542
HttpClient.ResponseWrapper<String> response = headBlob(containerRef);
531543
handleError(response);
532544
String size = response.headers().get(Const.CONTENT_LENGTH_HEADER.toLowerCase());
533-
String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
534-
return Descriptor.of(digest, Long.parseLong(size), Const.DEFAULT_DESCRIPTOR_MEDIA_TYPE);
545+
return Descriptor.of(
546+
validateDockerContentDigest(response), Long.parseLong(size), Const.DEFAULT_DESCRIPTOR_MEDIA_TYPE);
535547
}
536548

537549
@Override
@@ -562,15 +574,16 @@ public Descriptor getDescriptor(ContainerRef containerRef) {
562574
HttpClient.ResponseWrapper<String> response = getManifestResponse(containerRef);
563575
handleError(response);
564576
String size = response.headers().get(Const.CONTENT_LENGTH_HEADER.toLowerCase());
565-
String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
566577
String contentType = response.headers().get(Const.CONTENT_TYPE_HEADER.toLowerCase());
567-
return Descriptor.of(digest, Long.parseLong(size), contentType).withJson(response.response());
578+
return Descriptor.of(validateDockerContentDigest(response), Long.parseLong(size), contentType)
579+
.withJson(response.response());
568580
}
569581

570582
@Override
571583
public Descriptor probeDescriptor(ContainerRef ref) {
572584
Map<String, String> headers = getHeaders(ref);
573-
String digest = headers.get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
585+
String digest = validateDockerContentDigest(headers);
586+
SupportedAlgorithm.fromDigest(digest);
574587
String contentType = headers.get(Const.CONTENT_TYPE_HEADER.toLowerCase());
575588
return Descriptor.of(digest, 0L, contentType);
576589
}
@@ -594,16 +607,58 @@ private HttpClient.ResponseWrapper<String> getManifestResponse(ContainerRef cont
594607
uri, Map.of("Accept", Const.MANIFEST_ACCEPT_TYPE), Scopes.of(this, containerRef), authProvider);
595608
}
596609

597-
private byte[] ensureDigest(ContainerRef ref, byte[] data) {
610+
private void validateDockerContentDigest(HttpClient.ResponseWrapper<String> response, byte[] data) {
611+
String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
612+
// This might happen when blob are hosted other storage.
613+
// We need a way to propagate the headers like scoped.
614+
// For now just skip validation
615+
if (digest == null) {
616+
LOG.warn("Docker-Content-Digest header not found in response. Skipping validation.");
617+
return;
618+
}
619+
String computedDigest = SupportedAlgorithm.fromDigest(digest).digest(data);
620+
ensureDigest(digest, computedDigest);
621+
}
622+
623+
private void validateDockerContentDigest(HttpClient.ResponseWrapper<Path> response, Path path) {
624+
String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
625+
// This might happen when blob are hosted other storage.
626+
// We need a way to propagate the headers like scoped.
627+
// For now just skip validation
628+
if (digest == null) {
629+
LOG.warn("Docker-Content-Digest header not found in response. Skipping validation.");
630+
return;
631+
}
632+
String computedDigest = SupportedAlgorithm.fromDigest(digest).digest(path);
633+
ensureDigest(digest, computedDigest);
634+
}
635+
636+
private String validateDockerContentDigest(HttpClient.ResponseWrapper<?> response) {
637+
return validateDockerContentDigest(response.headers());
638+
}
639+
640+
private String validateDockerContentDigest(Map<String, String> headers) {
641+
String digest = headers.get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
642+
SupportedAlgorithm.fromDigest(digest);
643+
return digest;
644+
}
645+
646+
private void ensureDigest(ContainerRef ref, byte[] data) {
598647
if (ref.getDigest() == null) {
599648
throw new OrasException("Missing digest");
600649
}
601650
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(ref.getDigest());
602651
String dataDigest = algorithm.digest(data);
603-
if (!ref.getDigest().equals(dataDigest)) {
604-
throw new OrasException("Digest mismatch: %s != %s".formatted(ref.getDigest(), dataDigest));
652+
ensureDigest(ref.getDigest(), dataDigest);
653+
}
654+
655+
private void ensureDigest(String expected, @Nullable String current) {
656+
if (current == null) {
657+
throw new OrasException("Received null digest");
658+
}
659+
if (!expected.equals(current)) {
660+
throw new OrasException("Digest mismatch: %s != %s".formatted(expected, current));
605661
}
606-
return data;
607662
}
608663

609664
/**

src/main/java/land/oras/utils/SupportedAlgorithm.java

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.regex.Pattern;
2626
import land.oras.exception.OrasException;
2727
import org.jspecify.annotations.NullMarked;
28+
import org.jspecify.annotations.Nullable;
2829

2930
/**
3031
* Supported algorithms for digest.
@@ -38,27 +39,28 @@ public enum SupportedAlgorithm {
3839
* SHA-1
3940
* This is unsecure, only useful when computing digests for git content (like Flux CD)
4041
*/
41-
SHA1("SHA-1", "sha1"),
42+
SHA1("SHA-1", "sha1", 20),
4243

4344
/**
4445
* SHA-256
4546
*/
46-
SHA256("SHA-256", "sha256"),
47+
SHA256("SHA-256", "sha256", 32),
4748

4849
/**
4950
* SHA-384
5051
*/
51-
SHA384("SHA-384", "sha384"),
52+
SHA384("SHA-384", "sha384", 48),
5253

5354
/**
5455
* SHA-512
5556
*/
56-
SHA512("SHA-512", "sha512"),
57+
SHA512("SHA-512", "sha512", 64),
5758

5859
/**
5960
* BLAKE3
6061
*/
61-
BLAKE3("BLAKE3-256", "blake3");
62+
BLAKE3("BLAKE3-256", "blake3", 32),
63+
;
6264

6365
/**
6466
* The algorithm
@@ -70,6 +72,11 @@ public enum SupportedAlgorithm {
7072
*/
7173
private final String prefix;
7274

75+
/**
76+
* Size of the digest in bytes
77+
*/
78+
private final int size;
79+
7380
/**
7481
* Regex for a digest
7582
* <a href="https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests">Digests</a>
@@ -81,9 +88,10 @@ public enum SupportedAlgorithm {
8188
* @param algorithm The algorithm
8289
* @param prefix The prefix
8390
*/
84-
SupportedAlgorithm(String algorithm, String prefix) {
91+
SupportedAlgorithm(String algorithm, String prefix, int size) {
8592
this.algorithm = algorithm;
8693
this.prefix = prefix;
94+
this.size = size;
8795
}
8896

8997
/**
@@ -102,6 +110,14 @@ public String getAlgorithmName() {
102110
return algorithm;
103111
}
104112

113+
/**
114+
* Get the size of the digest
115+
* @return The size
116+
*/
117+
public int getSize() {
118+
return size;
119+
}
120+
105121
/**
106122
* Digest a byte array
107123
* @param bytes The bytes
@@ -134,7 +150,7 @@ public String digest(InputStream inputStream) {
134150
* @param digest The digest
135151
* @return True if supported
136152
*/
137-
public static boolean matchPattern(String digest) {
153+
static boolean matchPattern(String digest) {
138154
return DIGEST_REGEX.matcher(digest).matches();
139155
}
140156

@@ -149,6 +165,12 @@ public static boolean isSupported(String digest) {
149165
}
150166
for (SupportedAlgorithm algorithm : SupportedAlgorithm.values()) {
151167
if (digest.startsWith(algorithm.getPrefix())) {
168+
// Check the size
169+
String value = digest.substring(algorithm.getPrefix().length() + 1);
170+
if (value.length() != algorithm.getSize() * 2) {
171+
throw new OrasException("Invalid digest %s, expected size is %d, but got %d"
172+
.formatted(digest, algorithm.getSize(), value.length()));
173+
}
152174
return true;
153175
}
154176
}
@@ -160,7 +182,10 @@ public static boolean isSupported(String digest) {
160182
* @param digest The digest
161183
* @return The algorithm
162184
*/
163-
public static SupportedAlgorithm fromDigest(String digest) {
185+
public static SupportedAlgorithm fromDigest(@Nullable String digest) {
186+
if (digest == null) {
187+
throw new OrasException("Digest is null");
188+
}
164189
if (!DIGEST_REGEX.matcher(digest).matches()) {
165190
throw new OrasException("Invalid digest: " + digest);
166191
}

src/test/java/land/oras/DockerIoITCase.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
import java.nio.file.Path;
2727
import org.junit.jupiter.api.Test;
2828
import org.junit.jupiter.api.io.TempDir;
29+
import org.junit.jupiter.api.parallel.Execution;
30+
import org.junit.jupiter.api.parallel.ExecutionMode;
2931

32+
@Execution(ExecutionMode.CONCURRENT)
3033
public class DockerIoITCase {
3134

3235
@TempDir

src/test/java/land/oras/GitHubContainerRegistryITCase.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
import java.nio.file.Path;
2727
import org.junit.jupiter.api.Test;
2828
import org.junit.jupiter.api.io.TempDir;
29+
import org.junit.jupiter.api.parallel.Execution;
30+
import org.junit.jupiter.api.parallel.ExecutionMode;
2931

32+
@Execution(ExecutionMode.CONCURRENT)
3033
public class GitHubContainerRegistryITCase {
3134

3235
@TempDir

src/test/java/land/oras/JFrogArtifactoryITCase.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
import java.nio.file.Path;
2727
import org.junit.jupiter.api.Test;
2828
import org.junit.jupiter.api.io.TempDir;
29+
import org.junit.jupiter.api.parallel.Execution;
30+
import org.junit.jupiter.api.parallel.ExecutionMode;
2931

32+
@Execution(ExecutionMode.CONCURRENT)
3033
public class JFrogArtifactoryITCase {
3134

3235
@TempDir

src/test/java/land/oras/LayoutRefTest.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,14 @@ void shouldParseLayoutWithAllParts() {
5454
@Test
5555
void shouldParseLayoutWithDigest() {
5656
String ociLayout = tempDir.resolve("foo").toString();
57-
LayoutRef layoutRef = LayoutRef.parse("%s@sha256:12345".formatted(ociLayout));
58-
assertEquals("sha256:12345", layoutRef.getTag());
57+
LayoutRef layoutRef = LayoutRef.parse(
58+
"%s@sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824".formatted(ociLayout));
59+
assertEquals("sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", layoutRef.getTag());
5960
assertEquals(ociLayout, layoutRef.getFolder().toString());
6061
assertEquals(ociLayout, layoutRef.getRepository());
61-
assertTrue(layoutRef.isValidDigest(), "sha256:12345 should be a valid digest pattern");
62+
assertTrue(
63+
layoutRef.isValidDigest(),
64+
"sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 should be a valid digest pattern");
6265
}
6366

6467
@Test
@@ -85,9 +88,10 @@ void shouldFailWithInvalidRef() {
8588
void shouldGetAlgorithm() {
8689
LayoutRef layoutRef = LayoutRef.parse("foo");
8790
assertEquals("sha256", layoutRef.getAlgorithm().getPrefix());
88-
layoutRef = LayoutRef.parse("foo@sha256:12345");
91+
layoutRef = LayoutRef.parse("foo@sha256:");
8992
assertEquals("sha256", layoutRef.getAlgorithm().getPrefix());
90-
layoutRef = LayoutRef.parse("foo@sha512:12345");
93+
layoutRef = LayoutRef.parse(
94+
"foo@sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e");
9195
assertEquals("sha512", layoutRef.getAlgorithm().getPrefix());
9296
}
9397
}

0 commit comments

Comments
 (0)