diff --git a/src/main/java/dev/netcopy/App.java b/src/main/java/dev/netcopy/App.java index 6a4511f..35e13f1 100644 --- a/src/main/java/dev/netcopy/App.java +++ b/src/main/java/dev/netcopy/App.java @@ -37,6 +37,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.SecureRandom; +import java.time.Duration; import java.util.Base64; import java.util.List; import java.util.Set; @@ -132,6 +133,10 @@ sharedRoots, receiveRoots, resolveStateDir(stateDir), HashCache hashCache = new HashCache(); ProgressBus progressBus = new InMemoryProgressBus(); ManifestRegistry manifestRegistry = new ManifestRegistry(); + // Pre-v0.4.0 the registry's background TTL eviction was never started — entries + // accumulated forever on long-running daemons. 15 min sweep is well under the 24h + // default TTL so eviction is timely without burning CPU on a quiet box. + manifestRegistry.startBackgroundCleanup(Duration.ofMinutes(15)); ExecutorService backgroundExec = Executors.newVirtualThreadPerTaskExecutor(); ManifestPlanner.PlanConfig planConfig = new ManifestPlanner.PlanConfig( @@ -160,6 +165,11 @@ sharedRoots, receiveRoots, resolveStateDir(stateDir), Javalin app = Javalin.create(cfg -> { cfg.showJavalinBanner = false; + // Cap body size so an authenticated client can't OOM us by posting a 10 GB + // manifest. 64 MiB is comfortably above any realistic manifest (each entry is + // ~200 bytes of JSON, so 64 MiB is ~300k files) but well below "trivial DoS". + // Javalin's default in 6.x is ~1 MiB which is too low for big-tree manifests. + cfg.http.maxRequestSize = 64L * 1024L * 1024L; // Serve the SPA from src/main/resources/web/ (classpath). cfg.staticFiles.add(staticCfg -> { staticCfg.hostedPath = "/"; diff --git a/src/main/java/dev/netcopy/server/ManifestRegistry.java b/src/main/java/dev/netcopy/server/ManifestRegistry.java index c03aefd..28138b2 100644 --- a/src/main/java/dev/netcopy/server/ManifestRegistry.java +++ b/src/main/java/dev/netcopy/server/ManifestRegistry.java @@ -35,6 +35,17 @@ public final class ManifestRegistry implements AutoCloseable { /** Default time-to-live for manifests stored without an explicit TTL: 24 hours. */ public static final Duration DEFAULT_TTL = Duration.ofHours(24); + /** + * Hard cap on the number of stored manifests, INCLUDING expired-but-not-yet-evicted ones. + * Even with the background cleanup running every 15 min, a burst of plan requests could + * accumulate enough entries to OOM the JVM if no upper bound were enforced. At 10k entries + * × ~5 KiB each (a typical manifest with ~25 file entries), the registry caps at ~50 MiB + * worst case — comfortable on any sensible host. When full, the OLDEST entry is evicted + * to make room (LRU-by-storedAt rather than reject — manifests are commonly produced in + * bursts and a small batch of brand-new entries is more useful than a sticky old one). + */ + public static final int MAX_ENTRIES = 10_000; + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); private final Duration ttl; private final TimeSource clock; @@ -80,7 +91,29 @@ public Manifest store(Manifest m) { if (closed.get()) { throw new IllegalStateException("ManifestRegistry is closed"); } - entries.put(m.manifestId(), new Entry(m, clock.nowMillis())); + long now = clock.nowMillis(); + // Evict the oldest entry if we're at the cap. Cheap when not full; O(N) with the + // current map when the cap is hit, which only happens on truly pathological burst + // patterns (10k manifests in flight). Acceptable. + if (entries.size() >= MAX_ENTRIES && !entries.containsKey(m.manifestId())) { + UUID oldestKey = null; + long oldestTs = Long.MAX_VALUE; + for (Map.Entry kv : entries.entrySet()) { + long ts = kv.getValue().storedAt(); + if (ts < oldestTs) { + oldestTs = ts; + oldestKey = kv.getKey(); + } + } + if (oldestKey != null) { + entries.remove(oldestKey); + if (log.isDebugEnabled()) { + log.debug("ManifestRegistry: evicted oldest entry to stay at MAX_ENTRIES={}", + MAX_ENTRIES); + } + } + } + entries.put(m.manifestId(), new Entry(m, now)); return m; } diff --git a/src/main/java/dev/netcopy/server/ProgressWebSocket.java b/src/main/java/dev/netcopy/server/ProgressWebSocket.java index 9ea9a46..c10de22 100644 --- a/src/main/java/dev/netcopy/server/ProgressWebSocket.java +++ b/src/main/java/dev/netcopy/server/ProgressWebSocket.java @@ -161,6 +161,15 @@ private static void onClose(WsCloseContext ctx) { * ConcurrentHashMap forbids null keys, so we map the wildcard to this constant. */ private static final String WILDCARD_KEY = "*"; + /** + * Hard per-session cap on the number of distinct transferIds (incl. the wildcard) that a + * single WebSocket session may subscribe to. Prevents an authenticated client from creating + * 2^31 left-over ProgressBus subscriptions and OOM-ing the server. The UI in practice ever + * holds either one wildcard sub or a handful of transfer-specific subs, so 256 is well + * above honest use. + */ + private static final int MAX_SUBS_PER_SESSION = 256; + private static void doSubscribe(WsContext ctx, ProgressBus bus, String transferId) { Map subs = subsOf(ctx); if (subs == null) { @@ -172,6 +181,11 @@ private static void doSubscribe(WsContext ctx, ProgressBus bus, String transferI if (subs.containsKey(key)) { return; } + if (subs.size() >= MAX_SUBS_PER_SESSION) { + log.warn("ws: dropping Subscribe(transferId={}) on session {} — subscription cap {} reached", + transferId, ctx.sessionId(), MAX_SUBS_PER_SESSION); + return; + } AutoCloseable subscription = bus.subscribe(transferId, ev -> deliver(ctx, ev)); AutoCloseable previous = subs.putIfAbsent(key, subscription); if (previous != null) { diff --git a/src/main/java/dev/netcopy/server/RelayRoutes.java b/src/main/java/dev/netcopy/server/RelayRoutes.java index ca34e5a..605a49a 100644 --- a/src/main/java/dev/netcopy/server/RelayRoutes.java +++ b/src/main/java/dev/netcopy/server/RelayRoutes.java @@ -60,6 +60,25 @@ public final class RelayRoutes { private static final String X_TOKEN_HEADER = "X-NetCopy-Token"; + /** + * Lazily-built singleton {@link HttpClient}. Pre-v0.4.0 the handler created a fresh + * HttpClient per request — JDK 21+ keeps two background selector threads alive per client + * until shutdown(), so under sustained relay traffic the JVM accumulated thread per + * request × 2 until GC reclaimed them. One shared client serves the whole process. + */ + private static final java.util.concurrent.atomic.AtomicReference SHARED_CLIENT = + new java.util.concurrent.atomic.AtomicReference<>(); + + private static HttpClient sharedClient() { + HttpClient existing = SHARED_CLIENT.get(); + if (existing != null) return existing; + HttpClient created = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(HTTP_TIMEOUT) + .build(); + return SHARED_CLIENT.compareAndSet(null, created) ? created : SHARED_CLIENT.get(); + } + private RelayRoutes() {} /** Wires {@code POST /api/relay/push} onto {@code app}. */ @@ -112,10 +131,7 @@ private static void handle(Context ctx) { return; } - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(HTTP_TIMEOUT) - .build(); + HttpClient client = sharedClient(); HttpRequest req = HttpRequest.newBuilder(peerEndpoint) .timeout(HTTP_TIMEOUT) .header("Content-Type", "application/json") diff --git a/src/main/java/dev/netcopy/server/tcp/BlobTcpServer.java b/src/main/java/dev/netcopy/server/tcp/BlobTcpServer.java index 468d37c..5b3cd95 100644 --- a/src/main/java/dev/netcopy/server/tcp/BlobTcpServer.java +++ b/src/main/java/dev/netcopy/server/tcp/BlobTcpServer.java @@ -59,6 +59,17 @@ public dev.netcopy.metrics.ServeMetrics serveMetrics() { private volatile boolean running; private volatile int boundPort = -1; + /** + * Hard cap on concurrent active connections. Prevents an attacker from opening N FDs + * worth of idle (or HELLO-incomplete) sockets and starving the server of file + * descriptors. The HELLO timeout in TcpConnectionHandler caps how long an + * unauthenticated socket can hold a slot; this is the global ceiling. 1024 is a + * comfortable headroom over realistic peer-pull traffic (a single peer typically + * opens chunksPerFile=8 sockets per active file, so 1024 supports ~128 in-flight + * files concurrently — far above any sensible workload). + */ + public static final int MAX_CONCURRENT_CONNECTIONS = 1024; + /** Live connection threads — used to interrupt them on {@link #close()}. */ private final ConcurrentHashMap connectionThreads = new ConcurrentHashMap<>(); @@ -202,6 +213,15 @@ private void acceptLoop() { try { remote = String.valueOf(client.getRemoteAddress()); } catch (IOException ignored) { /* best-effort */ } + // Cap concurrent connections — close incoming over the limit immediately. + // Done after accept so the kernel SYN queue doesn't back up; dropping at + // the application layer is fine for a soft cap. + if (connectionThreads.size() >= MAX_CONCURRENT_CONNECTIONS) { + log.warn("blob-tcp rejecting accept from {} — at MAX_CONCURRENT_CONNECTIONS={}", + remote, MAX_CONCURRENT_CONNECTIONS); + try { client.close(); } catch (IOException ignored) { /* swallow */ } + continue; + } log.info("blob-tcp accept from {}", remote); String remoteFinal = remote; Thread.ofVirtual() diff --git a/src/main/java/dev/netcopy/server/tcp/TcpConnectionHandler.java b/src/main/java/dev/netcopy/server/tcp/TcpConnectionHandler.java index 5a4fc4a..86741df 100644 --- a/src/main/java/dev/netcopy/server/tcp/TcpConnectionHandler.java +++ b/src/main/java/dev/netcopy/server/tcp/TcpConnectionHandler.java @@ -74,6 +74,15 @@ final class TcpConnectionHandler { * read the real digest from the trailing {@link Frame.DataEndV2}. */ private static final byte[] ZERO_HASH = new byte[16]; + /** + * Wall-clock deadline for an unauthenticated client to deliver its HELLO frame. A + * Slowloris-style attacker that opens TCP and sends a single byte (or nothing) would + * otherwise hold a virtual thread + an FD indefinitely. SocketChannel in blocking mode + * doesn't honour SO_TIMEOUT (that's a Socket-level facility), so we implement the deadline + * as a watchdog that force-closes the channel if HELLO hasn't completed in time. + */ + private static final long HELLO_TIMEOUT_MS = 30_000L; + private final TokenGate tokens; private final PathResolver resolver; private final Function> manifests; @@ -101,17 +110,40 @@ final class TcpConnectionHandler { * {@code client} when this method returns. */ void handle(SocketChannel client) { + // Watchdog: force-close the channel if HELLO doesn't arrive within HELLO_TIMEOUT_MS. + // A virtual thread is the cheapest possible timer here (~kilobytes of stack, no + // shared scheduler state). The handshakeDone flag is checked when the watchdog + // wakes up — if HELLO already succeeded, the close is skipped. + final java.util.concurrent.atomic.AtomicBoolean handshakeDone = + new java.util.concurrent.atomic.AtomicBoolean(false); + Thread watchdog = Thread.ofVirtual().name("tcp-hello-watchdog").start(() -> { + try { + Thread.sleep(HELLO_TIMEOUT_MS); + } catch (InterruptedException ie) { + return; + } + if (!handshakeDone.get() && client.isOpen()) { + log.warn("handshake: timing out client {} after {}ms — no HELLO seen", + remoteOf(client), HELLO_TIMEOUT_MS); + try { client.close(); } catch (IOException ignored) { /* swallow */ } + } + }); try { byte negotiatedVer = performHandshake(client); + handshakeDone.set(true); + watchdog.interrupt(); if (negotiatedVer < 0) { return; } mainLoop(client, negotiatedVer); } catch (ClosedChannelException e) { - // Expected on shutdown / client disconnect. + // Expected on shutdown / client disconnect / HELLO watchdog firing. log.debug("connection closed", e); } catch (IOException e) { log.debug("connection IO error", e); + } finally { + handshakeDone.set(true); + watchdog.interrupt(); } } diff --git a/src/main/java/dev/netcopy/state/JsonJobStore.java b/src/main/java/dev/netcopy/state/JsonJobStore.java index 3316842..6e8988c 100644 --- a/src/main/java/dev/netcopy/state/JsonJobStore.java +++ b/src/main/java/dev/netcopy/state/JsonJobStore.java @@ -7,15 +7,22 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; @@ -37,6 +44,28 @@ public final class JsonJobStore implements JobStore { private static final String SUFFIX = ".json"; private static final String TMP_SUFFIX = ".json.tmp"; + /** {@code rwx------} for the jobs directory on POSIX filesystems. Job-state files contain + * the full manifest (absolute paths, file sizes, mtimes); on multi-user hosts other local + * users should not be able to read what NetCopy is currently transferring. No-op on + * Windows (the FS doesn't support PosixFileAttributeView). */ + private static final Set DIR_PERMS = EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE); + + /** {@code rw-------} for the per-job JSON files. Same rationale as DIR_PERMS. */ + private static final Set FILE_PERMS = EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + + /** Whether the JVM's default filesystem supports POSIX permissions (Linux, macOS = yes; + * Windows = no). Computed once at class init. */ + private static final boolean POSIX_FS; + static { + FileSystem fs = FileSystems.getDefault(); + POSIX_FS = fs.supportedFileAttributeViews().contains("posix"); + } + private final Path jobsDir; private final ObjectMapper mapper; @@ -165,6 +194,14 @@ private void writeAtomic(JobState job) { StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE ); + // Tighten perms on the tmp file before the rename — perms move with it. On + // Windows POSIX_FS is false and this is a no-op (the FS uses ACLs). Best-effort: + // a permission failure here doesn't block the write. + if (POSIX_FS) { + try { + Files.setPosixFilePermissions(tmpPath, FILE_PERMS); + } catch (IOException ignored) { /* best-effort; default umask survives */ } + } try { Files.move( tmpPath, @@ -191,7 +228,19 @@ private void writeAtomic(JobState job) { private void ensureJobsDir() { try { - Files.createDirectories(jobsDir); + if (POSIX_FS) { + FileAttribute> attr = + PosixFilePermissions.asFileAttribute(DIR_PERMS); + Files.createDirectories(jobsDir, attr); + // createDirectories applies the attr only to dirs it CREATES — if the dir + // exists already (left over from a previous run with default perms) we + // tighten it now. Best-effort. + try { + Files.setPosixFilePermissions(jobsDir, DIR_PERMS); + } catch (IOException ignored) { /* best-effort */ } + } else { + Files.createDirectories(jobsDir); + } } catch (java.nio.file.AccessDeniedException e) { // The most common cause when running with --user "$(id -u):$(id -g)" against a // named Docker volume: the volume's mount point was created as root by Docker