Skip to content

Commit 18617e0

Browse files
authored
Pull integrity checks (#784)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent e5a9bb8 commit 18617e0

4 files changed

Lines changed: 250 additions & 49 deletions

File tree

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

Lines changed: 121 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import io.micrometer.core.instrument.MeterRegistry;
2424
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
25+
import java.io.FilterInputStream;
2526
import java.io.IOException;
2627
import java.io.InputStream;
2728
import java.net.URI;
@@ -31,8 +32,10 @@
3132
import java.nio.file.Path;
3233
import java.nio.file.StandardCopyOption;
3334
import java.security.MessageDigest;
35+
import java.security.NoSuchAlgorithmException;
3436
import java.util.ArrayList;
3537
import java.util.HashMap;
38+
import java.util.HexFormat;
3639
import java.util.List;
3740
import java.util.Map;
3841
import java.util.Objects;
@@ -1160,24 +1163,11 @@ public byte[] getBlob(ContainerRef containerRef) {
11601163
}
11611164

11621165
private byte[] getBlobDirect(ContainerRef containerRef) {
1163-
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
1164-
if (ref.isInsecure(this) && !this.isInsecure()) {
1165-
return copyForNewTransport(ref.getRegistry(), true).getBlobDirect(ref);
1166-
}
1167-
if (!ref.isInsecure(this) && this.isInsecure()) {
1168-
return copyForNewTransport(ref.getRegistry(), false).getBlobDirect(ref);
1166+
try (InputStream is = fetchBlobDirect(containerRef)) {
1167+
return is.readAllBytes();
1168+
} catch (IOException e) {
1169+
throw new OrasException("Failed to get blob", e);
11691170
}
1170-
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this)));
1171-
HttpClient.ResponseWrapper<String> response = client.get(
1172-
uri,
1173-
Map.of(Const.ACCEPT_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE),
1174-
Scopes.of(ref),
1175-
authProvider);
1176-
logResponse(response);
1177-
handleError(response);
1178-
byte[] data = response.response().getBytes(StandardCharsets.UTF_8);
1179-
validateDockerContentDigest(response, data);
1180-
return data;
11811171
}
11821172

11831173
@Override
@@ -1207,7 +1197,7 @@ private void fetchBlobDirect(ContainerRef containerRef, Path path) {
12071197
authProvider);
12081198
logResponse(response);
12091199
handleError(response);
1210-
validateDockerContentDigest(response, path);
1200+
verifyBlobDigest(ref, path, response.headers());
12111201
}
12121202

12131203
@Override
@@ -1231,8 +1221,30 @@ private InputStream fetchBlobDirect(ContainerRef containerRef) {
12311221
authProvider);
12321222
logResponse(response);
12331223
handleError(response);
1234-
validateDockerContentDigest(response);
1235-
return response.response();
1224+
// Verify the content digest incrementally
1225+
List<String> expected = expectedBlobDigests(ref, response.headers());
1226+
if (expected.isEmpty()) {
1227+
return response.response();
1228+
}
1229+
return new DigestVerifyingInputStream(response.response(), expected);
1230+
}
1231+
1232+
/**
1233+
* Collect the digests a downloaded blob must match: the caller-pinned digest (when it is a supported
1234+
* digest and not a plain tag) and the server-advertised {@code Docker-Content-Digest} header (when
1235+
* present). Duplicates are collapsed so the content is hashed once per distinct algorithm.
1236+
*/
1237+
private static List<String> expectedBlobDigests(ContainerRef ref, Map<String, String> headers) {
1238+
List<String> expected = new ArrayList<>(2);
1239+
String pinned = ref.getDigest();
1240+
if (pinned != null && SupportedAlgorithm.isSupported(pinned)) {
1241+
expected.add(pinned);
1242+
}
1243+
String header = headers.get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
1244+
if (header != null && !expected.contains(header)) {
1245+
expected.add(header);
1246+
}
1247+
return expected;
12361248
}
12371249

12381250
@Override
@@ -1304,16 +1316,16 @@ public Descriptor getDescriptor(ContainerRef containerRef) {
13041316
HttpClient.ResponseWrapper<String> response = getManifestResponse(containerRef);
13051317
logResponse(response);
13061318
handleError(response);
1319+
String json = response.response();
1320+
// When the reference is pinned to a digest, verify the returned manifest/index bytes against it
1321+
verifyPinnedDigest(containerRef, json.getBytes(StandardCharsets.UTF_8));
13071322
String size = response.headers().get(Const.CONTENT_LENGTH_HEADER.toLowerCase());
13081323
String contentType = response.headers().get(Const.CONTENT_TYPE_HEADER.toLowerCase());
13091324
return Descriptor.of(
13101325
validateDockerContentDigest(response),
1311-
Long.parseLong(
1312-
size == null
1313-
? String.valueOf(response.response().length())
1314-
: size),
1326+
Long.parseLong(size == null ? String.valueOf(json.length()) : size),
13151327
contentType)
1316-
.withJson(response.response());
1328+
.withJson(json);
13171329
}
13181330

13191331
@Override
@@ -1407,30 +1419,37 @@ private HttpClient.ResponseWrapper<String> getManifestResponseDirect(ContainerRe
14071419
return client.get(uri, Map.of("Accept", Const.MANIFEST_ACCEPT_TYPE), Scopes.of(ref), authProvider);
14081420
}
14091421

1410-
private void validateDockerContentDigest(HttpClient.ResponseWrapper<String> response, byte[] data) {
1411-
String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
1412-
// This might happen when blob are hosted other storage.
1413-
// We need a way to propagate the headers like scoped.
1414-
// For now just skip validation
1415-
if (digest == null) {
1416-
LOG.debug("Docker-Content-Digest header not found in response. Skipping validation.");
1422+
/**
1423+
* Verify in-memory content against the digest pinned in the reference. No-op when the reference is
1424+
* not digest-pinned (e.g. pulled by tag), since there is then no trusted value to check against.
1425+
* @param ref The reference the content was requested for
1426+
* @param content The bytes returned by the registry
1427+
*/
1428+
private void verifyPinnedDigest(ContainerRef ref, byte[] content) {
1429+
String digest = ref.getDigest();
1430+
if (digest == null || !SupportedAlgorithm.isSupported(digest)) {
14171431
return;
14181432
}
1419-
String computedDigest = SupportedAlgorithm.fromDigest(digest).digest(data);
1420-
ensureDigest(digest, computedDigest);
1433+
ensureDigest(digest, SupportedAlgorithm.fromDigest(digest).digest(content));
14211434
}
14221435

1423-
private void validateDockerContentDigest(HttpClient.ResponseWrapper<Path> response, Path path) {
1424-
String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
1425-
// This might happen when blob are hosted other storage.
1426-
// We need a way to propagate the headers like scoped.
1427-
// For now just skip validation
1428-
if (digest == null) {
1429-
LOG.debug("Docker-Content-Digest header not found in response. Skipping validation.");
1430-
return;
1436+
/**
1437+
* Verify a downloaded blob against every digest that applies (pinned or from header)
1438+
* @param ref The reference the blob was requested for
1439+
* @param content The path the registry response was written to
1440+
* @param headers The response headers
1441+
*/
1442+
private void verifyBlobDigest(ContainerRef ref, Path content, Map<String, String> headers) {
1443+
String pinned = ref.getDigest();
1444+
String verified = null;
1445+
if (pinned != null && SupportedAlgorithm.isSupported(pinned)) {
1446+
ensureDigest(pinned, SupportedAlgorithm.fromDigest(pinned).digest(content));
1447+
verified = pinned;
1448+
}
1449+
String header = headers.get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
1450+
if (header != null && !header.equals(verified)) {
1451+
ensureDigest(header, SupportedAlgorithm.fromDigest(header).digest(content));
14311452
}
1432-
String computedDigest = SupportedAlgorithm.fromDigest(digest).digest(path);
1433-
ensureDigest(digest, computedDigest);
14341453
}
14351454

14361455
private @Nullable String validateDockerContentDigest(HttpClient.ResponseWrapper<?> response) {
@@ -1439,9 +1458,7 @@ private void validateDockerContentDigest(HttpClient.ResponseWrapper<Path> respon
14391458

14401459
private @Nullable String validateDockerContentDigest(Map<String, String> headers) {
14411460
String digest = headers.get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase());
1442-
// This might happen when blob are hosted other storage.
1443-
// We need a way to propagate the headers like scoped.
1444-
// For now just skip validation
1461+
// Not mandatory, but require validation if present
14451462
if (digest == null) {
14461463
LOG.debug("Docker-Content-Digest header not found in response. Skipping validation.");
14471464
return null;
@@ -2001,4 +2018,61 @@ public Registry build() {
20012018
return registry.build();
20022019
}
20032020
}
2021+
2022+
/**
2023+
* A stream that verifies the content digest incrementally as it is read, without buffering to a
2024+
* temporary file. It updates one {@link MessageDigest} per distinct algorithm as bytes pass through
2025+
* and, at end-of-stream
2026+
*/
2027+
private static final class DigestVerifyingInputStream extends FilterInputStream {
2028+
2029+
private final List<String> expectedDigests;
2030+
private final Map<String, MessageDigest> digestsByPrefix = new HashMap<>();
2031+
private boolean verified;
2032+
2033+
private DigestVerifyingInputStream(InputStream in, List<String> expectedDigests) {
2034+
super(in);
2035+
this.expectedDigests = expectedDigests;
2036+
for (String expected : expectedDigests) {
2037+
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(expected);
2038+
digestsByPrefix.computeIfAbsent(algorithm.getPrefix(), p -> {
2039+
try {
2040+
return MessageDigest.getInstance(algorithm.getAlgorithmName());
2041+
} catch (NoSuchAlgorithmException e) {
2042+
throw new OrasException("Unsupported digest algorithm: " + algorithm.getAlgorithmName(), e);
2043+
}
2044+
});
2045+
}
2046+
}
2047+
2048+
@Override
2049+
public int read(byte[] buffer, int off, int len) throws IOException {
2050+
int read = super.read(buffer, off, len);
2051+
if (read > 0) {
2052+
for (MessageDigest digest : digestsByPrefix.values()) {
2053+
digest.update(buffer, off, read);
2054+
}
2055+
} else if (read < 0) {
2056+
verify();
2057+
}
2058+
return read;
2059+
}
2060+
2061+
private void verify() {
2062+
if (verified) {
2063+
return;
2064+
}
2065+
verified = true;
2066+
Map<String, String> actualByPrefix = new HashMap<>();
2067+
digestsByPrefix.forEach((prefix, digest) ->
2068+
actualByPrefix.put(prefix, prefix + ":" + HexFormat.of().formatHex(digest.digest())));
2069+
for (String expected : expectedDigests) {
2070+
String prefix = SupportedAlgorithm.fromDigest(expected).getPrefix();
2071+
String actual = actualByPrefix.get(prefix);
2072+
if (!expected.equals(actual)) {
2073+
throw new OrasException("Digest mismatch: %s != %s".formatted(expected, actual));
2074+
}
2075+
}
2076+
}
2077+
}
20042078
}

src/test/java/land/oras/RegistryWireMockTest.java

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
package land.oras;
2222

2323
import static com.github.tomakehurst.wiremock.client.WireMock.*;
24+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
2425
import static org.junit.jupiter.api.Assertions.assertEquals;
2526
import static org.junit.jupiter.api.Assertions.assertFalse;
2627
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -1435,8 +1436,10 @@ void shouldFailCopyWhenIndexNestingExceedsMaxDepth(WireMockRuntimeInfo wmRuntime
14351436
// A malicious source serves an unbounded chain of nested indexes
14361437
int levels = 40;
14371438
String[] digests = new String[levels + 2];
1438-
for (int i = 0; i < digests.length; i++) {
1439-
digests[i] = SupportedAlgorithm.SHA256.digest(("deep-chain-" + i).getBytes(StandardCharsets.UTF_8));
1439+
digests[levels + 1] = SupportedAlgorithm.SHA256.digest("deep-chain-leaf".getBytes(StandardCharsets.UTF_8));
1440+
for (int i = levels; i >= 0; i--) {
1441+
digests[i] = SupportedAlgorithm.SHA256.digest(
1442+
nestedIndexJson(digests[i + 1]).getBytes(StandardCharsets.UTF_8));
14401443
}
14411444

14421445
// Deeper levels
@@ -1554,4 +1557,128 @@ private static void stubNestedSourceIndex(
15541557
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, selfDigest)
15551558
.withBody(nestedIndexJson(childDigest))));
15561559
}
1560+
1561+
@Test
1562+
void shouldRejectBlobWhenContentDoesNotMatchPinnedDigest(WireMockRuntimeInfo wmRuntimeInfo) {
1563+
String pinnedDigest = SupportedAlgorithm.SHA256.digest("good-data".getBytes(StandardCharsets.UTF_8));
1564+
String evil = "evil-data";
1565+
String selfConsistentHeader = SupportedAlgorithm.SHA256.digest(evil.getBytes(StandardCharsets.UTF_8));
1566+
1567+
WireMock wireMock = wmRuntimeInfo.getWireMock();
1568+
wireMock.register(WireMock.get(WireMock.urlEqualTo("/v2/library/evil-blob/blobs/%s".formatted(pinnedDigest)))
1569+
.willReturn(WireMock.ok()
1570+
.withBody(evil)
1571+
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, selfConsistentHeader)));
1572+
1573+
Registry registry = Registry.Builder.builder()
1574+
.withAuthProvider(authProvider)
1575+
.withInsecure(true)
1576+
.build();
1577+
ContainerRef ref = ContainerRef.parse("localhost:%d/library/evil-blob".formatted(wmRuntimeInfo.getHttpPort()))
1578+
.withDigest(pinnedDigest);
1579+
1580+
OrasException ex = assertThrows(OrasException.class, () -> registry.getBlob(ref));
1581+
assertTrue(ex.getMessage().contains("Digest mismatch"), "Unexpected: " + ex.getMessage());
1582+
}
1583+
1584+
@Test
1585+
void shouldReturnBinaryBlobWithoutCorruption(WireMockRuntimeInfo wmRuntimeInfo) {
1586+
byte[] binary = new byte[] {0x00, (byte) 0xC3, 0x28, (byte) 0xFF, (byte) 0x80, 0x7F, (byte) 0xFE};
1587+
String digest = SupportedAlgorithm.SHA256.digest(binary);
1588+
1589+
WireMock wireMock = wmRuntimeInfo.getWireMock();
1590+
wireMock.register(WireMock.get(WireMock.urlEqualTo("/v2/library/bin-blob/blobs/%s".formatted(digest)))
1591+
.willReturn(WireMock.ok().withBody(binary).withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, digest)));
1592+
1593+
Registry registry = Registry.Builder.builder()
1594+
.withAuthProvider(authProvider)
1595+
.withInsecure(true)
1596+
.build();
1597+
ContainerRef ref = ContainerRef.parse("localhost:%d/library/bin-blob".formatted(wmRuntimeInfo.getHttpPort()))
1598+
.withDigest(digest);
1599+
1600+
assertArrayEquals(binary, registry.getBlob(ref));
1601+
}
1602+
1603+
@Test
1604+
void shouldRejectFetchBlobToPathOnDigestMismatch(WireMockRuntimeInfo wmRuntimeInfo) {
1605+
String pinnedDigest = SupportedAlgorithm.SHA256.digest("good".getBytes(StandardCharsets.UTF_8));
1606+
1607+
WireMock wireMock = wmRuntimeInfo.getWireMock();
1608+
wireMock.register(WireMock.get(WireMock.urlEqualTo("/v2/library/evil-file/blobs/%s".formatted(pinnedDigest)))
1609+
.willReturn(WireMock.ok().withBody("tampered")));
1610+
1611+
Registry registry = Registry.Builder.builder()
1612+
.withAuthProvider(authProvider)
1613+
.withInsecure(true)
1614+
.build();
1615+
ContainerRef ref = ContainerRef.parse("localhost:%d/library/evil-file".formatted(wmRuntimeInfo.getHttpPort()))
1616+
.withDigest(pinnedDigest);
1617+
Path out = configDir.resolve("blob-out.bin");
1618+
1619+
OrasException ex = assertThrows(OrasException.class, () -> registry.fetchBlob(ref, out));
1620+
assertTrue(ex.getMessage().contains("Digest mismatch"), "Unexpected: " + ex.getMessage());
1621+
}
1622+
1623+
@Test
1624+
void shouldRejectBlobStreamOnDigestMismatch(WireMockRuntimeInfo wmRuntimeInfo) {
1625+
String pinnedDigest = SupportedAlgorithm.SHA256.digest("good".getBytes(StandardCharsets.UTF_8));
1626+
1627+
WireMock wireMock = wmRuntimeInfo.getWireMock();
1628+
wireMock.register(WireMock.get(WireMock.urlEqualTo("/v2/library/evil-stream/blobs/%s".formatted(pinnedDigest)))
1629+
.willReturn(WireMock.ok().withBody("tampered")));
1630+
1631+
Registry registry = Registry.Builder.builder()
1632+
.withAuthProvider(authProvider)
1633+
.withInsecure(true)
1634+
.build();
1635+
ContainerRef ref = ContainerRef.parse("localhost:%d/library/evil-stream".formatted(wmRuntimeInfo.getHttpPort()))
1636+
.withDigest(pinnedDigest);
1637+
1638+
// The digest is verified incrementally, so the mismatch surfaces when the stream is read to EOF.
1639+
OrasException ex = assertThrows(OrasException.class, () -> {
1640+
try (InputStream is = registry.getBlobStream(ref)) {
1641+
is.readAllBytes();
1642+
}
1643+
});
1644+
assertTrue(ex.getMessage().contains("Digest mismatch"), "Unexpected: " + ex.getMessage());
1645+
}
1646+
1647+
@Test
1648+
void shouldRejectManifestWhoseContentDoesNotMatchPinnedDigest(WireMockRuntimeInfo wmRuntimeInfo) {
1649+
String realManifest =
1650+
"""
1651+
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",\
1652+
"config":{"mediaType":"application/vnd.oci.empty.v1+json",\
1653+
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},\
1654+
"layers":[]}""";
1655+
String evilManifest = realManifest.replace("\"layers\":[]", "\"layers\":[],\"annotations\":{\"x\":\"y\"}");
1656+
String pinnedDigest = SupportedAlgorithm.SHA256.digest(realManifest.getBytes(StandardCharsets.UTF_8));
1657+
String selfConsistentHeader = SupportedAlgorithm.SHA256.digest(evilManifest.getBytes(StandardCharsets.UTF_8));
1658+
1659+
WireMock wireMock = wmRuntimeInfo.getWireMock();
1660+
String manifestPath = "/v2/library/evil-manifest/manifests/%s".formatted(pinnedDigest);
1661+
wireMock.register(WireMock.head(WireMock.urlEqualTo(manifestPath))
1662+
.willReturn(WireMock.aResponse()
1663+
.withStatus(200)
1664+
.withHeader(Const.CONTENT_TYPE_HEADER, Const.DEFAULT_MANIFEST_MEDIA_TYPE)
1665+
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, selfConsistentHeader)));
1666+
wireMock.register(WireMock.get(WireMock.urlEqualTo(manifestPath))
1667+
.willReturn(WireMock.aResponse()
1668+
.withStatus(200)
1669+
.withHeader(Const.CONTENT_TYPE_HEADER, Const.DEFAULT_MANIFEST_MEDIA_TYPE)
1670+
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, selfConsistentHeader)
1671+
.withBody(evilManifest)));
1672+
1673+
Registry registry = Registry.Builder.builder()
1674+
.withAuthProvider(authProvider)
1675+
.withInsecure(true)
1676+
.build();
1677+
ContainerRef ref = ContainerRef.parse(
1678+
"localhost:%d/library/evil-manifest".formatted(wmRuntimeInfo.getHttpPort()))
1679+
.withDigest(pinnedDigest);
1680+
1681+
OrasException ex = assertThrows(OrasException.class, () -> registry.getManifest(ref));
1682+
assertTrue(ex.getMessage().contains("Digest mismatch"), "Unexpected: " + ex.getMessage());
1683+
}
15571684
}
0 Bytes
Binary file not shown.

src/test/resources/oci/subject.tar

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)