Skip to content

Commit 2e369a4

Browse files
committed
fix: automatic backup implemented security fixes + validation
1 parent f6994f8 commit 2e369a4

7 files changed

Lines changed: 250 additions & 81 deletions

File tree

server/src/main/java/com/arcadedb/server/backup/AutoBackupSchedulerPlugin.java

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
import com.arcadedb.server.event.ServerEventLog;
2727

2828
import java.io.File;
29+
import java.io.IOException;
30+
import java.nio.file.Files;
31+
import java.nio.file.Path;
32+
import java.nio.file.Paths;
2933
import java.util.Set;
3034
import java.util.logging.Level;
3135

@@ -88,40 +92,19 @@ public void startService() {
8892
return;
8993
}
9094

91-
// Validate and resolve backup directory
95+
// Validate and resolve backup directory using consolidated security validation
9296
String backupDirectory = backupConfig.getBackupDirectory();
93-
final java.nio.file.Path backupPath = java.nio.file.Paths.get(backupDirectory);
97+
final Path serverRoot = Paths.get(server.getRootPath()).toAbsolutePath().normalize();
9498

95-
// Reject absolute paths for security
96-
if (backupPath.isAbsolute()) {
97-
throw new IllegalArgumentException(
98-
"Backup directory must be a relative path, not absolute: " + backupDirectory);
99-
}
99+
// Validate and get the resolved, secure path
100+
final Path resolvedPath = validateAndResolveBackupPath(backupDirectory, serverRoot);
101+
backupDirectory = resolvedPath.toString();
100102

101-
// Reject path traversal attempts
102-
if (backupDirectory.contains("..")) {
103-
throw new IllegalArgumentException(
104-
"Backup directory cannot contain path traversal (..): " + backupDirectory);
105-
}
106-
107-
// Resolve relative path against server root
108-
backupDirectory = java.nio.file.Paths.get(server.getRootPath(), backupDirectory).toString();
109-
110-
// Final validation: ensure resolved path is within server root
111-
final java.nio.file.Path resolvedPath = java.nio.file.Paths.get(backupDirectory).toAbsolutePath().normalize();
112-
final java.nio.file.Path serverRoot = java.nio.file.Paths.get(server.getRootPath()).toAbsolutePath().normalize();
113-
114-
if (!resolvedPath.startsWith(serverRoot)) {
115-
throw new IllegalArgumentException(
116-
"Backup directory must be within server root path for security reasons: " + backupDirectory);
117-
}
118-
119-
// Ensure backup directory exists
120-
final File backupDir = new File(backupDirectory);
121-
if (!backupDir.exists()) {
122-
if (!backupDir.mkdirs()) {
123-
throw new RuntimeException("Failed to create backup directory: " + backupDirectory);
124-
}
103+
// Ensure backup directory exists - use Files.createDirectories to avoid TOCTOU
104+
try {
105+
Files.createDirectories(resolvedPath);
106+
} catch (final IOException e) {
107+
throw new RuntimeException("Failed to create backup directory: " + backupDirectory, e);
125108
}
126109

127110
// Initialize retention manager
@@ -270,4 +253,60 @@ public void reloadConfiguration() {
270253

271254
LogManager.instance().log(this, Level.INFO, "Auto-backup configuration reloaded");
272255
}
256+
257+
/**
258+
* Validates and resolves a backup directory path with comprehensive security checks.
259+
* <p>
260+
* Security checks performed:
261+
* 1. Reject absolute paths
262+
* 2. Normalize path and reject if it escapes via ..
263+
* 3. Resolve against server root
264+
* 4. Verify final path is within server root
265+
* 5. Check for symlinks that could escape the root
266+
*
267+
* @param backupDir The backup directory path from configuration
268+
* @param serverRoot The server root directory (absolute, normalized)
269+
* @return The validated, resolved absolute path
270+
* @throws IllegalArgumentException if the path fails security validation
271+
*/
272+
public static Path validateAndResolveBackupPath(final String backupDir, final Path serverRoot) {
273+
if (backupDir == null || backupDir.isEmpty())
274+
throw new IllegalArgumentException("Backup directory cannot be empty");
275+
276+
final Path inputPath = Paths.get(backupDir);
277+
278+
// 1. Reject absolute paths
279+
if (inputPath.isAbsolute())
280+
throw new IllegalArgumentException(
281+
"Backup directory must be a relative path, not absolute: " + backupDir);
282+
283+
// 2. Normalize and check for path traversal
284+
final Path normalizedInput = inputPath.normalize();
285+
if (normalizedInput.startsWith(".."))
286+
throw new IllegalArgumentException(
287+
"Backup directory cannot escape server root via path traversal: " + backupDir);
288+
289+
// 3. Resolve against server root
290+
final Path resolvedPath = serverRoot.resolve(normalizedInput).normalize();
291+
292+
// 4. Verify resolved path is within server root
293+
if (!resolvedPath.startsWith(serverRoot))
294+
throw new IllegalArgumentException(
295+
"Backup directory must be within server root path: " + backupDir);
296+
297+
// 5. If path exists, check for symlinks that could escape the root
298+
if (Files.exists(resolvedPath)) {
299+
try {
300+
final Path realPath = resolvedPath.toRealPath();
301+
if (!realPath.startsWith(serverRoot))
302+
throw new IllegalArgumentException(
303+
"Backup directory symlink resolves outside server root: " + backupDir);
304+
} catch (final IOException e) {
305+
throw new IllegalArgumentException(
306+
"Cannot resolve backup directory path: " + backupDir, e);
307+
}
308+
}
309+
310+
return resolvedPath;
311+
}
273312
}

server/src/main/java/com/arcadedb/server/backup/BackupRetentionManager.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,34 +104,52 @@ public int applyRetention(final String databaseName) {
104104

105105
// Delete files not in the keep set
106106
int deletedCount = 0;
107+
int failedCount = 0;
107108
for (final BackupFileInfo info : backupFiles) {
108109
if (!filesToKeep.contains(info.file)) {
109-
if (info.file.delete()) {
110+
try {
111+
java.nio.file.Files.delete(info.file.toPath());
110112
deletedCount++;
111113
LogManager.instance().log(this, Level.INFO,
112114
"Deleted old backup: %s", info.file.getName());
113-
} else {
115+
} catch (final java.io.IOException e) {
116+
failedCount++;
114117
LogManager.instance().log(this, Level.WARNING,
115-
"Failed to delete old backup: %s", info.file.getName());
118+
"Failed to delete old backup '%s': %s", info.file.getName(), e.getMessage());
116119
}
117120
}
118121
}
119122

123+
if (failedCount > 0) {
124+
LogManager.instance().log(this, Level.WARNING,
125+
"Retention for database '%s' completed with %d deletion failures", databaseName, failedCount);
126+
}
127+
120128
LogManager.instance().log(this, Level.INFO,
121129
"Retention applied for database '%s': kept %d, deleted %d backups",
122130
databaseName, filesToKeep.size(), deletedCount);
123131

124132
return deletedCount;
125133
}
126134

135+
// Maximum number of backup files to process to prevent unbounded memory usage
136+
private static final int MAX_BACKUP_FILES_TO_PROCESS = 10000;
137+
127138
/**
128139
* Gets all backup files for a database directory, sorted by timestamp.
140+
* Limited to MAX_BACKUP_FILES_TO_PROCESS to prevent unbounded memory usage.
129141
*/
130142
private List<BackupFileInfo> getBackupFiles(final File dbBackupDir) {
131143
final File[] files = dbBackupDir.listFiles(BACKUP_FILE_FILTER);
132144
if (files == null || files.length == 0)
133145
return Collections.emptyList();
134146

147+
if (files.length > MAX_BACKUP_FILES_TO_PROCESS) {
148+
LogManager.instance().log(this, Level.WARNING,
149+
"Database backup directory contains %d files, processing only the most recent %d",
150+
files.length, MAX_BACKUP_FILES_TO_PROCESS);
151+
}
152+
135153
final List<BackupFileInfo> backupFiles = new ArrayList<>();
136154
for (final File file : files) {
137155
final LocalDateTime timestamp = parseBackupTimestamp(file.getName());
@@ -141,6 +159,13 @@ private List<BackupFileInfo> getBackupFiles(final File dbBackupDir) {
141159

142160
// Sort by timestamp (oldest first)
143161
backupFiles.sort(Comparator.comparing(info -> info.timestamp));
162+
163+
// Limit to most recent files if too many
164+
if (backupFiles.size() > MAX_BACKUP_FILES_TO_PROCESS) {
165+
final int startIndex = backupFiles.size() - MAX_BACKUP_FILES_TO_PROCESS;
166+
return new ArrayList<>(backupFiles.subList(startIndex, backupFiles.size()));
167+
}
168+
144169
return backupFiles;
145170
}
146171

server/src/main/java/com/arcadedb/server/backup/BackupScheduler.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import com.arcadedb.server.ArcadeDBServer;
2323

2424
import java.time.LocalDateTime;
25+
import java.util.ArrayList;
26+
import java.util.List;
2527
import java.util.Map;
2628
import java.util.concurrent.*;
2729
import java.util.logging.Level;
@@ -153,7 +155,8 @@ private void scheduleNextCronExecution(final String databaseName, final BackupTa
153155
task.run();
154156
} finally {
155157
// Schedule the next execution only if still running and parser is still registered
156-
if (running && cronParsers.containsKey(databaseName)) {
158+
// Use atomic get to avoid race condition between containsKey and get
159+
if (running) {
157160
final CronScheduleParser currentParser = cronParsers.get(databaseName);
158161
if (currentParser != null)
159162
scheduleNextCronExecution(databaseName, task, currentParser);
@@ -202,8 +205,9 @@ public void triggerImmediateBackup(final String databaseName, final DatabaseBack
202205
public void stop() {
203206
running = false;
204207

205-
// Cancel all scheduled tasks
206-
for (final ScheduledFuture<?> future : scheduledTasks.values())
208+
// Cancel all scheduled tasks - create copy to avoid ConcurrentModificationException
209+
final List<ScheduledFuture<?>> tasksToCancel = new ArrayList<>(scheduledTasks.values());
210+
for (final ScheduledFuture<?> future : tasksToCancel)
207211
future.cancel(false);
208212
scheduledTasks.clear();
209213
cronParsers.clear();

server/src/main/java/com/arcadedb/server/backup/BackupTask.java

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
import com.arcadedb.server.event.ServerEventLog;
2626
import com.arcadedb.server.ha.HAServer;
2727

28-
import java.io.File;
28+
import java.lang.reflect.Constructor;
2929
import java.lang.reflect.InvocationTargetException;
30+
import java.lang.reflect.Method;
3031
import java.time.LocalDateTime;
3132
import java.time.LocalTime;
3233
import java.time.format.DateTimeFormatter;
@@ -40,6 +41,15 @@
4041
public class BackupTask implements Runnable {
4142
private static final DateTimeFormatter BACKUP_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
4243

44+
// Cached reflection objects for better performance
45+
private static volatile Class<?> backupClass;
46+
private static volatile Constructor<?> backupConstructor;
47+
private static volatile Method setDirectoryMethod;
48+
private static volatile Method setVerboseLevelMethod;
49+
private static volatile Method backupDatabaseMethod;
50+
private static volatile boolean reflectionInitialized;
51+
private static final Object REFLECTION_LOCK = new Object();
52+
4353
private final ArcadeDBServer server;
4454
private final String databaseName;
4555
private final DatabaseBackupConfig config;
@@ -150,48 +160,79 @@ private boolean isWithinTimeWindow() {
150160

151161
/**
152162
* Performs the actual backup using the integration Backup class.
163+
* <p>
164+
* Note: The backup mechanism in ArcadeDB reads from immutable pages and handles
165+
* consistency internally. The transaction check is a safety warning but does not
166+
* block new transactions - the backup is designed to be non-blocking.
153167
*/
154168
private String performBackup() throws Exception {
155169
final Database database = server.getDatabase(databaseName);
156170

157-
// Check for active transaction - never automatically rollback as it could cause data loss
158-
if (database.isTransactionActive() && ((DatabaseInternal) database).getTransaction().hasChanges()) {
159-
throw new BackupException("Cannot perform backup for database '" + databaseName +
160-
"': active transaction with pending changes detected. Please commit or rollback the transaction manually.");
171+
// Check for active transaction - warn but don't block
172+
// ArcadeDB backup is designed to work on immutable pages, so this is informational
173+
if (database.isTransactionActive()) {
174+
final DatabaseInternal dbInternal = (DatabaseInternal) database;
175+
if (dbInternal.getTransaction().hasChanges()) {
176+
LogManager.instance().log(this, Level.WARNING,
177+
"Backup for database '%s' starting with active transaction - uncommitted changes will not be included",
178+
databaseName);
179+
}
161180
}
162181

163182
// Generate backup filename
164183
final String timestamp = LocalDateTime.now().format(BACKUP_TIMESTAMP_FORMAT);
165184
final String backupFileName = databaseName + "-backup-" + timestamp + ".zip";
166185

167-
// Prepare backup directory for this database
168-
final String dbBackupDir = java.nio.file.Paths.get(backupDirectory, databaseName).toString();
169-
final File backupDirFile = new File(dbBackupDir);
170-
if (!backupDirFile.exists()) {
171-
if (!backupDirFile.mkdirs()) {
172-
throw new BackupException("Failed to create backup directory for database '" + databaseName + "': " + dbBackupDir);
173-
}
186+
// Prepare backup directory for this database - use Files.createDirectories to avoid TOCTOU
187+
final java.nio.file.Path dbBackupPath = java.nio.file.Paths.get(backupDirectory, databaseName);
188+
try {
189+
java.nio.file.Files.createDirectories(dbBackupPath);
190+
} catch (final java.io.IOException e) {
191+
throw new BackupException("Failed to create backup directory for database '" + databaseName + "': " + dbBackupPath, e);
174192
}
193+
final String dbBackupDir = dbBackupPath.toString();
175194

195+
// Perform backup using cached reflection for better performance
176196
try {
177-
final Class<?> clazz = Class.forName("com.arcadedb.integration.backup.Backup");
178-
final Object backup = clazz.getConstructor(Database.class, String.class)
179-
.newInstance(database, backupFileName);
197+
initializeReflection();
180198

181-
clazz.getMethod("setDirectory", String.class).invoke(backup, dbBackupDir);
182-
clazz.getMethod("setVerboseLevel", Integer.TYPE).invoke(backup, 1);
199+
final Object backup = backupConstructor.newInstance(database, backupFileName);
200+
setDirectoryMethod.invoke(backup, dbBackupDir);
201+
setVerboseLevelMethod.invoke(backup, 1);
183202

184-
final String backupFile = (String) clazz.getMethod("backupDatabase").invoke(backup);
185-
return backupFile;
203+
return (String) backupDatabaseMethod.invoke(backup);
186204

187-
} catch (final ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InstantiationException e) {
205+
} catch (final IllegalAccessException | InstantiationException e) {
188206
throw new BackupException("Backup libs not found in classpath. Make sure arcadedb-integration module is " +
189207
"included.", e);
190208
} catch (final InvocationTargetException e) {
191209
throw new BackupException("Error performing backup for database '" + databaseName + "'", e.getTargetException());
192210
}
193211
}
194212

213+
/**
214+
* Initializes the cached reflection objects for the Backup class.
215+
* Uses double-checked locking for thread-safe lazy initialization.
216+
*/
217+
private static void initializeReflection() throws BackupException {
218+
if (!reflectionInitialized) {
219+
synchronized (REFLECTION_LOCK) {
220+
if (!reflectionInitialized) {
221+
try {
222+
backupClass = Class.forName("com.arcadedb.integration.backup.Backup");
223+
backupConstructor = backupClass.getConstructor(Database.class, String.class);
224+
setDirectoryMethod = backupClass.getMethod("setDirectory", String.class);
225+
setVerboseLevelMethod = backupClass.getMethod("setVerboseLevel", Integer.TYPE);
226+
backupDatabaseMethod = backupClass.getMethod("backupDatabase");
227+
reflectionInitialized = true;
228+
} catch (final ClassNotFoundException | NoSuchMethodException e) {
229+
throw new BackupException("Backup libs not found in classpath. Make sure arcadedb-integration module is included.", e);
230+
}
231+
}
232+
}
233+
}
234+
}
235+
195236
public String getDatabaseName() {
196237
return databaseName;
197238
}

server/src/main/java/com/arcadedb/server/backup/CronScheduleParser.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,11 @@ public LocalDateTime getNextExecutionTime(final LocalDateTime from) {
139139
LocalDateTime next = from.plusSeconds(1).withNano(0);
140140

141141
// Find next matching time by incrementing fields
142-
int iterations = 0;
143-
final int maxIterations = 366 * 24 * 60 * 60; // One year of seconds max
142+
// Use a reasonable limit: 4 years should cover any valid CRON expression
143+
// This prevents potential infinite loops from impossible CRON expressions
144+
final LocalDateTime maxDate = from.plusYears(4);
144145

145-
while (iterations++ < maxIterations) {
146+
while (next.isBefore(maxDate)) {
146147
// Check month
147148
if (!months.get(next.getMonthValue())) {
148149
next = next.plusMonths(1).withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
@@ -184,7 +185,9 @@ public LocalDateTime getNextExecutionTime(final LocalDateTime from) {
184185
return next;
185186
}
186187

187-
throw new IllegalStateException("Could not find next execution time for CRON expression: " + expression);
188+
throw new IllegalStateException(
189+
"Could not find next execution time within 4 years for CRON expression '" + expression +
190+
"'. The expression may be invalid or specify an impossible schedule.");
188191
}
189192

190193
/**

0 commit comments

Comments
 (0)