fix: harden local archive sink
This commit is contained in:
@@ -5,12 +5,16 @@ import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
public final class LocalArchiveStore implements ArchiveStore {
|
||||
|
||||
private final Path root;
|
||||
private final ConcurrentMap<Path, Object> appendLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public LocalArchiveStore(String root) {
|
||||
this(rootPath(root));
|
||||
@@ -26,12 +30,14 @@ public final class LocalArchiveStore implements ArchiveStore {
|
||||
throw new IllegalArgumentException("data must not be null");
|
||||
}
|
||||
Path target = resolve(key);
|
||||
Files.createDirectories(target.getParent());
|
||||
createDirectoriesInsideRoot(target.getParent());
|
||||
rejectSymlinkPath(target);
|
||||
try (OutputStream out = Files.newOutputStream(
|
||||
target,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE)) {
|
||||
StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS)) {
|
||||
data.transferTo(out);
|
||||
}
|
||||
return target.toUri().toString();
|
||||
@@ -40,29 +46,38 @@ public final class LocalArchiveStore implements ArchiveStore {
|
||||
@Override
|
||||
public String append(String key, byte[] chunk) throws IOException {
|
||||
Path target = resolve(key);
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.write(
|
||||
target,
|
||||
chunk == null ? new byte[0] : chunk,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND,
|
||||
StandardOpenOption.WRITE);
|
||||
synchronized (appendLocks.computeIfAbsent(target, ignored -> new Object())) {
|
||||
createDirectoriesInsideRoot(target.getParent());
|
||||
rejectSymlinkPath(target);
|
||||
Files.write(
|
||||
target,
|
||||
chunk == null ? new byte[0] : chunk,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND,
|
||||
StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
return target.toUri().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream get(String key) throws IOException {
|
||||
return Files.newInputStream(resolve(key), StandardOpenOption.READ);
|
||||
Path target = resolveExisting(key);
|
||||
return Files.newInputStream(target, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String key) {
|
||||
return Files.exists(resolve(key));
|
||||
try {
|
||||
return Files.exists(resolveExisting(key), LinkOption.NOFOLLOW_LINKS);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long size(String key) throws IOException {
|
||||
return Files.size(resolve(key));
|
||||
return Files.size(resolveExisting(key));
|
||||
}
|
||||
|
||||
private Path resolve(String key) {
|
||||
@@ -76,6 +91,53 @@ public final class LocalArchiveStore implements ArchiveStore {
|
||||
return target;
|
||||
}
|
||||
|
||||
private Path resolveExisting(String key) throws IOException {
|
||||
Path target = resolve(key);
|
||||
rejectSymlinkPath(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
private void createDirectoriesInsideRoot(Path directory) throws IOException {
|
||||
rejectEscapedPath(directory);
|
||||
if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(root);
|
||||
}
|
||||
Path current = root;
|
||||
Path relative = root.relativize(directory);
|
||||
for (Path segment : relative) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IOException("archive path traverses symlink: " + current);
|
||||
}
|
||||
if (Files.notExists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectory(current);
|
||||
} else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("archive path segment is not a directory: " + current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void rejectSymlinkPath(Path target) throws IOException {
|
||||
rejectEscapedPath(target);
|
||||
Path current = root;
|
||||
Path relative = root.relativize(target);
|
||||
for (Path segment : relative) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IOException("archive path traverses symlink: " + current);
|
||||
}
|
||||
if (Files.notExists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void rejectEscapedPath(Path path) {
|
||||
if (!path.normalize().startsWith(root)) {
|
||||
throw new IllegalArgumentException("archive path escapes root: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path rootPath(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive");
|
||||
|
||||
@@ -25,8 +25,11 @@ public final class RawArchiveEventSink implements EventSink {
|
||||
if (!(event instanceof VehicleEvent.RawArchive raw)) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
byte[] bytes = raw.rawBytes() == null ? new byte[0] : raw.rawBytes();
|
||||
try {
|
||||
byte[] bytes = raw.rawBytes();
|
||||
if (bytes == null) {
|
||||
throw new IllegalArgumentException("raw archive bytes must not be null");
|
||||
}
|
||||
store.put(RawArchiveKeys.key(raw), new ByteArrayInputStream(bytes), bytes.length);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.lingniu.ingest.sink.archive.LocalArchiveStore;
|
||||
import com.lingniu.ingest.sink.archive.RawArchiveEventSink;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -27,6 +28,7 @@ public class SinkArchiveAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnBean(ArchiveStore.class)
|
||||
public RawArchiveEventSink rawArchiveEventSink(ArchiveStore store) {
|
||||
return new RawArchiveEventSink(store);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user