Skip to content

Commit 6a89988

Browse files
authored
Ensure to check digest when reading blobs from OCI layout (#782)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent 2d2957b commit 6a89988

4 files changed

Lines changed: 148 additions & 0 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ public void pullArtifact(LayoutRef ref, Path path, PullOptions options) {
180180
.orElseThrow(() -> new OrasException("Layer not found with title annotation"));
181181

182182
Path blobPath = getBlobPath(layer);
183+
verifyBlobDigest(blobPath);
183184

184185
// Copy the blob to the target path
185186
try {
@@ -319,13 +320,35 @@ public void fetchBlob(LayoutRef ref, Path path) {
319320
@Override
320321
public InputStream fetchBlob(LayoutRef ref) {
321322
Path blobPath = getBlobPath(ref);
323+
verifyBlobDigest(blobPath);
322324
try {
323325
return Files.newInputStream(blobPath);
324326
} catch (IOException e) {
325327
throw new OrasException("Failed to fetch blob", e);
326328
}
327329
}
328330

331+
void verifyBlobDigest(Path blobPath) {
332+
// A missing blob is left for the caller to surface (Files.newInputStream / Files.copy).
333+
if (!Files.exists(blobPath)) {
334+
return;
335+
}
336+
Path fileName = blobPath.getFileName();
337+
Path parent = blobPath.getParent();
338+
if (fileName == null || parent == null || parent.getFileName() == null) {
339+
throw new OrasException("Cannot resolve expected digest for blob path: %s".formatted(blobPath));
340+
}
341+
String expectedDigest = "%s:%s".formatted(parent.getFileName(), fileName);
342+
if (!SupportedAlgorithm.isSupported(expectedDigest)) {
343+
throw new OrasException("Blob is not stored at a content-addressed path: %s".formatted(blobPath));
344+
}
345+
String actualDigest = SupportedAlgorithm.fromDigest(expectedDigest).digest(blobPath);
346+
if (!expectedDigest.equals(actualDigest)) {
347+
throw new OrasException("Blob integrity check failed for %s: expected %s but on-disk content hashes to %s"
348+
.formatted(blobPath, expectedDigest, actualDigest));
349+
}
350+
}
351+
329352
@Override
330353
public Descriptor fetchBlobDescriptor(LayoutRef ref) {
331354
if (ref.getTag() == null) {

src/test/java/land/oras/OCILayoutTest.java

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,131 @@ void shouldPushConfigWithReference() throws IOException {
272272
assertEquals("hello", new String(content.readAllBytes(), StandardCharsets.UTF_8));
273273
}
274274

275+
@Test
276+
void shouldRejectTamperedBlobOnRead() throws IOException {
277+
Path path = layoutPath.resolve("tamperedBlob");
278+
LayoutRef layoutRef = LayoutRef.parse("%s".formatted(path.toString()));
279+
OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build();
280+
281+
// Push a known blob into the layout
282+
Path blobFile = blobDir.resolve("blob.txt");
283+
Files.writeString(blobFile, "hello");
284+
String digest = SupportedAlgorithm.getDefault().digest(blobFile);
285+
ociLayout.pushBlob(layoutRef.withDigest(digest), blobFile);
286+
287+
// Untampered blob reads back correctly
288+
assertEquals("hello", new String(ociLayout.getBlob(layoutRef.withDigest(digest)), StandardCharsets.UTF_8));
289+
290+
// Tamper the blob on disk at its digest-derived path (simulating a co-tenant / shared FS)
291+
String hex = digest.substring(digest.indexOf(':') + 1);
292+
Path onDisk = path.resolve(Const.OCI_LAYOUT_BLOBS).resolve("sha256").resolve(hex);
293+
Files.writeString(onDisk, "evil");
294+
295+
// The tampered content must be rejected rather than served as authentic
296+
LayoutRef tamperedRef = layoutRef.withDigest(digest);
297+
OrasException viaGetBlob = assertThrows(OrasException.class, () -> ociLayout.getBlob(tamperedRef));
298+
assertTrue(
299+
viaGetBlob.getMessage().contains("integrity check failed"),
300+
"Unexpected message: " + viaGetBlob.getMessage());
301+
302+
// Every blob read path (getBlob, fetchBlob(ref), fetchBlob(ref, path)) is protected
303+
assertThrows(OrasException.class, () -> ociLayout.fetchBlob(tamperedRef));
304+
Path out = extractDir.resolve("out.bin");
305+
assertThrows(OrasException.class, () -> ociLayout.fetchBlob(tamperedRef, out));
306+
}
307+
308+
@Test
309+
void shouldReadUntamperedBlobAfterIntegrityCheck() throws IOException {
310+
Path path = layoutPath.resolve("untamperedBlob");
311+
LayoutRef layoutRef = LayoutRef.parse("%s".formatted(path.toString()));
312+
OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build();
313+
314+
Path blobFile = blobDir.resolve("intact.txt");
315+
Files.writeString(blobFile, "intact-content");
316+
String digest = SupportedAlgorithm.getDefault().digest(blobFile);
317+
ociLayout.pushBlob(layoutRef.withDigest(digest), blobFile);
318+
319+
// The integrity check must not break a legitimate, untouched blob on any read path
320+
LayoutRef ref = layoutRef.withDigest(digest);
321+
assertEquals("intact-content", new String(ociLayout.getBlob(ref), StandardCharsets.UTF_8));
322+
try (InputStream is = ociLayout.fetchBlob(ref)) {
323+
assertEquals("intact-content", new String(is.readAllBytes(), StandardCharsets.UTF_8));
324+
}
325+
Path out = extractDir.resolve("intact-out.txt");
326+
ociLayout.fetchBlob(ref, out);
327+
assertEquals("intact-content", Files.readString(out));
328+
}
329+
330+
@Test
331+
void shouldRejectTamperedLayerOnPullArtifact() throws IOException {
332+
Path ociLayoutPath = layoutPath.resolve("tamperedLayerPull");
333+
Path artifactPath = blobDir.resolve("artifact.txt");
334+
Files.writeString(artifactPath, "artifact-content");
335+
336+
LayoutRef layoutRef = LayoutRef.parse("%s:latest".formatted(ociLayoutPath.toString()));
337+
OCILayout ociLayout =
338+
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
339+
Annotations annotations = Annotations.ofManifest(Map.of(Const.ANNOTATION_CREATED, Const.currentTimestamp()));
340+
ociLayout.pushArtifact(
341+
layoutRef, ArtifactType.from("foo/bar"), annotations, LocalPath.of(artifactPath, "text/plain"));
342+
343+
// Tamper the layer blob on disk at its digest-derived path
344+
String digest = SupportedAlgorithm.SHA256.digest(artifactPath);
345+
String hex = digest.substring(digest.indexOf(':') + 1);
346+
Path onDisk =
347+
ociLayoutPath.resolve(Const.OCI_LAYOUT_BLOBS).resolve("sha256").resolve(hex);
348+
Files.writeString(onDisk, "tampered-artifact");
349+
350+
Path target = extractDir.resolve("pull-target");
351+
Files.createDirectories(target);
352+
OrasException ex = assertThrows(OrasException.class, () -> ociLayout.pullArtifact(layoutRef, target, true));
353+
assertTrue(ex.getMessage().contains("integrity check failed"), "Unexpected message: " + ex.getMessage());
354+
}
355+
356+
@Test
357+
void verifyBlobDigestRejectsNonContentAddressedPath() throws IOException {
358+
Path ociLayoutPath = layoutPath.resolve("nonCasPath");
359+
OCILayout ociLayout =
360+
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
361+
362+
// An existing file whose "<parentDir>:<fileName>" is not a valid digest ("notanalgo:somefile")
363+
Path notContentAddressed = blobDir.resolve("notanalgo").resolve("somefile");
364+
Files.createDirectories(notContentAddressed.getParent());
365+
Files.writeString(notContentAddressed, "data");
366+
367+
OrasException ex = assertThrows(OrasException.class, () -> ociLayout.verifyBlobDigest(notContentAddressed));
368+
assertTrue(
369+
ex.getMessage().contains("not stored at a content-addressed path"),
370+
"Unexpected message: " + ex.getMessage());
371+
}
372+
373+
@Test
374+
void verifyBlobDigestRejectsPathWithoutResolvableDigest() {
375+
Path ociLayoutPath = layoutPath.resolve("noDigestPath");
376+
OCILayout ociLayout =
377+
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
378+
379+
// The filesystem root exists but has no file name (and no parent), so no digest can be derived
380+
Path root = layoutPath.getRoot();
381+
assertNotNull(root);
382+
assertNull(root.getFileName());
383+
OrasException ex = assertThrows(OrasException.class, () -> ociLayout.verifyBlobDigest(root));
384+
assertTrue(
385+
ex.getMessage().contains("Cannot resolve expected digest"), "Unexpected message: " + ex.getMessage());
386+
}
387+
388+
@Test
389+
void verifyBlobDigestIgnoresMissingBlob() {
390+
Path ociLayoutPath = layoutPath.resolve("missingBlob");
391+
OCILayout ociLayout =
392+
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
393+
394+
// A missing blob is not the integrity check's concern: it returns without throwing and lets
395+
// the caller (Files.newInputStream / Files.copy) surface the absence.
396+
Path missing = blobDir.resolve("does-not-exist");
397+
assertDoesNotThrow(() -> ociLayout.verifyBlobDigest(missing));
398+
}
399+
275400
@Test
276401
void shouldPushIndexWithTag() {
277402
Path path = layoutPath.resolve("shouldPushIndexWithTag");
0 Bytes
Binary file not shown.

src/test/resources/oci/subject.tar

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)