fix: harden local archive sink

This commit is contained in:
lingniu
2026-06-23 15:54:41 +08:00
parent 019512bac2
commit d7afb8e599
6 changed files with 257 additions and 13 deletions

View File

@@ -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");

View File

@@ -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) {

View File

@@ -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);
}