Skip to content

Commit f42178b

Browse files
authored
Normalize and validate path from org.opencontainers.image.title (#703)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent 8a6ee6e commit f42178b

4 files changed

Lines changed: 148 additions & 2 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,13 @@ public void pullArtifact(LayoutRef ref, Path path, boolean overwrite) {
166166

167167
// Copy the blob to the target path
168168
try {
169-
Files.copy(blobPath, path.resolve(layer.getAnnotations().get(Const.ANNOTATION_TITLE)));
169+
Path targetPath = path.resolve(layer.getAnnotations().get(Const.ANNOTATION_TITLE))
170+
.normalize();
171+
if (!targetPath.startsWith(path.normalize())) {
172+
throw new OrasException("Refusing to pull layer: title annotation is not withing folder '%s'"
173+
.formatted(layer.getAnnotations().get(Const.ANNOTATION_TITLE)));
174+
}
175+
Files.copy(blobPath, targetPath);
170176
} catch (IOException e) {
171177
throw new OrasException("Failed to copy blob", e);
172178
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,13 @@ private void pullLayer(ContainerRef ref, Layer layer, Path path, boolean overwri
11091109
ArchiveUtils.untar(Files.newInputStream(tempArchive.getPath()), path);
11101110

11111111
} else {
1112-
Path targetPath = path.resolve(layer.getAnnotations().get(Const.ANNOTATION_TITLE));
1112+
Path targetPath = path.resolve(layer.getAnnotations().get(Const.ANNOTATION_TITLE))
1113+
.normalize();
1114+
if (!targetPath.startsWith(path.normalize())) {
1115+
throw new OrasException(
1116+
"Refusing to pull layer: path is not withing folder in title annotation '%s'"
1117+
.formatted(layer.getAnnotations().get(Const.ANNOTATION_TITLE)));
1118+
}
11131119
if (Files.exists(targetPath) && !overwrite) {
11141120
LOG.info("File already exists: {}", targetPath);
11151121
return;

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,4 +1149,64 @@ private void assertBlobContent(Path ociLayoutPath, String digest, String content
11491149
.resolve(SupportedAlgorithm.getDigest(digest))),
11501150
"Expect blob content to match");
11511151
}
1152+
1153+
@Test
1154+
void pullArtifactShouldRejectInvalidTitleAnnotation() throws IOException {
1155+
1156+
// Create a valid OCI layout via the normal push API
1157+
Path ociLayoutPath = layoutPath.resolve("traversal-test");
1158+
Path artifactFile = blobDir.resolve("safe.txt");
1159+
Files.writeString(artifactFile, "safe content");
1160+
1161+
LayoutRef layoutRef = LayoutRef.parse("%s:latest".formatted(ociLayoutPath.toString()));
1162+
OCILayout ociLayout =
1163+
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
1164+
ociLayout.pushArtifact(
1165+
layoutRef, ArtifactType.from("foo/bar"), Annotations.empty(), LocalPath.of(artifactFile, "text/plain"));
1166+
1167+
// 2. Read the manifest stored on disk and replace the title annotation with a traversal path
1168+
Index index = Index.fromPath(ociLayoutPath.resolve(Const.OCI_LAYOUT_INDEX));
1169+
String originalManifestDigest = index.getManifests().get(0).getDigest(); // e.g. sha256:abc...
1170+
Path manifestBlobPath = ociLayoutPath
1171+
.resolve("blobs")
1172+
.resolve("sha256")
1173+
.resolve(SupportedAlgorithm.getDigest(originalManifestDigest));
1174+
1175+
String originalManifestJson = Files.readString(manifestBlobPath);
1176+
1177+
// Invalid path
1178+
String tamperedManifestJson = originalManifestJson.replace("\"safe.txt\"", "\"../traversed-file.txt\"");
1179+
1180+
// Write tampered manifest as a new blob and update index.json
1181+
byte[] tamperedBytes = tamperedManifestJson.getBytes(StandardCharsets.UTF_8);
1182+
String tamperedDigest = SupportedAlgorithm.SHA256.digest(tamperedBytes);
1183+
Path tamperedBlobPath =
1184+
ociLayoutPath.resolve("blobs").resolve("sha256").resolve(SupportedAlgorithm.getDigest(tamperedDigest));
1185+
Files.write(tamperedBlobPath, tamperedBytes);
1186+
1187+
// Rewrite index.json to reference the tampered manifest digest
1188+
String updatedIndexJson = Files.readString(ociLayoutPath.resolve(Const.OCI_LAYOUT_INDEX))
1189+
.replace(
1190+
SupportedAlgorithm.getDigest(originalManifestDigest),
1191+
SupportedAlgorithm.getDigest(tamperedDigest));
1192+
Files.writeString(ociLayoutPath.resolve(Const.OCI_LAYOUT_INDEX), updatedIndexJson);
1193+
1194+
// Attempt to pull — must be rejected because the title escapes the output directory
1195+
Path outputDir = extractDir.resolve("traversal-output");
1196+
Files.createDirectories(outputDir);
1197+
1198+
OCILayout tampered = OCILayout.Builder.builder().defaults(ociLayoutPath).build();
1199+
OrasException exception = assertThrows(
1200+
OrasException.class,
1201+
() -> tampered.pullArtifact(layoutRef, outputDir, true),
1202+
"Expected OrasException for title annotation");
1203+
assertTrue(
1204+
exception.getMessage().contains("is not withing folder"),
1205+
"Exception message should mention is not withing folder but was: " + exception.getMessage());
1206+
1207+
// 5. The file must NOT have been written outside the output directory
1208+
assertFalse(
1209+
Files.exists(outputDir.getParent().resolve("traversed-file.txt")),
1210+
"Blob must not be written outside the output directory");
1211+
}
11521212
}

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
import static com.github.tomakehurst.wiremock.client.WireMock.*;
2424
import static org.junit.jupiter.api.Assertions.assertEquals;
2525
import static org.junit.jupiter.api.Assertions.assertFalse;
26+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2627
import static org.junit.jupiter.api.Assertions.assertNotNull;
2728
import static org.junit.jupiter.api.Assertions.assertThrows;
29+
import static org.junit.jupiter.api.Assertions.assertTrue;
2830

2931
import com.github.tomakehurst.wiremock.WireMockServer;
3032
import com.github.tomakehurst.wiremock.client.WireMock;
@@ -1072,4 +1074,76 @@ private byte[] tokenScenario(
10721074
ContainerRef.parse("localhost:%d/library/%s".formatted(wmRuntimeInfo.getHttpPort(), registryName));
10731075
return registry.getBlob(containerRef.withDigest(digest));
10741076
}
1077+
1078+
@Test
1079+
void pullArtifactShouldRejectInvalidTitleAnnotation(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
1080+
WireMock wireMock = wmRuntimeInfo.getWireMock();
1081+
String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "");
1082+
1083+
// Craft a blob and build a manifest whose layer title contains a invalid sequence
1084+
byte[] blobContent = "malicious content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
1085+
String blobDigest = SupportedAlgorithm.SHA256.digest(blobContent);
1086+
1087+
Layer maliciousLayer = Layer.fromDigest(blobDigest, blobContent.length)
1088+
.withAnnotations(Map.of(Const.ANNOTATION_TITLE, "../traversed-file.txt"));
1089+
1090+
Manifest manifest = Manifest.empty().withLayers(List.of(maliciousLayer));
1091+
String manifestJson = JsonUtils.toJson(manifest);
1092+
String manifestDigest =
1093+
SupportedAlgorithm.SHA256.digest(manifestJson.getBytes(java.nio.charset.StandardCharsets.UTF_8));
1094+
1095+
// Stub HEAD manifest
1096+
wireMock.register(head(urlEqualTo("/v2/library/malicious-artifact/manifests/latest"))
1097+
.willReturn(aResponse()
1098+
.withStatus(200)
1099+
.withHeader(Const.CONTENT_TYPE_HEADER, Const.DEFAULT_MANIFEST_MEDIA_TYPE)
1100+
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, manifestDigest)));
1101+
1102+
// Stub GET manifest
1103+
wireMock.register(get(urlEqualTo("/v2/library/malicious-artifact/manifests/latest"))
1104+
.willReturn(aResponse()
1105+
.withStatus(200)
1106+
.withHeader(Const.CONTENT_TYPE_HEADER, Const.DEFAULT_MANIFEST_MEDIA_TYPE)
1107+
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, manifestDigest)
1108+
.withBody(manifestJson)));
1109+
1110+
// Stub GET blob
1111+
wireMock.register(get(urlEqualTo("/v2/library/malicious-artifact/blobs/%s".formatted(blobDigest)))
1112+
.willReturn(aResponse()
1113+
.withStatus(200)
1114+
.withBody(blobContent)
1115+
.withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, blobDigest)));
1116+
1117+
Registry registry = Registry.Builder.builder()
1118+
.withAuthProvider(authProvider)
1119+
.withInsecure(true)
1120+
.build();
1121+
1122+
ContainerRef containerRef = ContainerRef.parse("%s/library/malicious-artifact:latest".formatted(registryUrl));
1123+
1124+
Path outputDir = configDir.resolve("pull-output");
1125+
Files.createDirectories(outputDir);
1126+
1127+
// Check exception
1128+
Throwable cause = assertThrows(
1129+
Exception.class,
1130+
() -> registry.pullArtifact(containerRef, outputDir, true),
1131+
"Expected an exception for title annotation");
1132+
while (cause.getCause() != null && !(cause instanceof OrasException)) {
1133+
cause = cause.getCause();
1134+
}
1135+
assertInstanceOf(
1136+
OrasException.class,
1137+
cause,
1138+
"Root cause should be OrasException but was: "
1139+
+ cause.getClass().getName());
1140+
assertTrue(
1141+
cause.getMessage().contains("is not withing folder"),
1142+
"Exception message should mention is not withing folder but was: " + cause.getMessage());
1143+
1144+
// The file must NOT have been written outside the output directory
1145+
assertFalse(
1146+
Files.exists(outputDir.getParent().resolve("traversed-file.txt")),
1147+
"Blob must not be written outside the output directory");
1148+
}
10751149
}

0 commit comments

Comments
 (0)