Skip to content

Commit 0038591

Browse files
committed
Harden snapshot names on server side
1 parent e28b9bd commit 0038591

3 files changed

Lines changed: 84 additions & 10 deletions

File tree

src/java/org/apache/cassandra/service/snapshot/SnapshotOptions.java

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Collections;
2424
import java.util.Map;
2525
import java.util.function.Predicate;
26+
import java.util.regex.Pattern;
2627

2728
import com.google.common.util.concurrent.RateLimiter;
2829

@@ -33,8 +34,10 @@
3334
import org.apache.cassandra.config.DurationSpec;
3435
import org.apache.cassandra.db.ColumnFamilyStore;
3536
import org.apache.cassandra.io.sstable.format.SSTableReader;
37+
import org.apache.cassandra.schema.SchemaConstants;
3638

3739
import static java.lang.String.format;
40+
import static org.apache.cassandra.schema.SchemaConstants.FILENAME_LENGTH;
3841

3942
public class SnapshotOptions
4043
{
@@ -88,7 +91,7 @@ public static SnapshotOptions userSnapshot(String tag, Map<String, String> optio
8891
return builder.build();
8992
}
9093

91-
public String getSnapshotName(Instant creationTime)
94+
public static String getSnapshotName(SnapshotType type, String tag, Instant creationTime)
9295
{
9396
// Diagnostic snapshots have very specific naming convention hence we are keeping it.
9497
// Repair snapshots rely on snapshots having name of their repair session ids
@@ -189,10 +192,47 @@ public Builder rateLimiter(RateLimiter rateLimiter)
189192
}
190193

191194
public SnapshotOptions build()
195+
{
196+
validateTag(tag);
197+
validateTTL(ephemeral, ttl);
198+
199+
if (rateLimiter == null)
200+
rateLimiter = DatabaseDescriptor.getSnapshotRateLimiter();
201+
202+
return new SnapshotOptions(this);
203+
}
204+
205+
private void validateTag(String tag)
192206
{
193207
if (tag == null || tag.isEmpty())
194208
throw new RuntimeException("You must supply a snapshot name.");
195209

210+
// Pre-generate snapshot name for the sake of the validation.
211+
// getSnapshotName logic does not return raw "tag" as snapshot name every time,
212+
// it e.g. prepends timestamp and type for system snapshots, and we need to validate it as a whole.
213+
// If, for example, tag would be less than max allowed FILENAME_LENGTH,
214+
// we might in fact produce a snapshot name longer than FILENAME_LENGTH if we prepended a timestamp to it.
215+
String resolvedSnapshotname = SnapshotOptions.getSnapshotName(type, tag, Instant.now());
216+
217+
// the length of valid snapshot name has to be less than or equal to FILENAME_LEGTH - that is 255 -
218+
// we are following the max length as it is in SchemaConstants for table name.
219+
if (resolvedSnapshotname.length() > SchemaConstants.FILENAME_LENGTH)
220+
{
221+
throw new IllegalArgumentException(format("Snapshot name must not be more than %d characters long for " +
222+
"resolved snapshot name (got %d characters for \"%s\")",
223+
FILENAME_LENGTH, resolvedSnapshotname.length(), resolvedSnapshotname));
224+
}
225+
226+
// "-" is important, we are already generating system snapshots with "-" in them so this has to stay
227+
// \\w are just "words" consisting of [a-zA-Z0-9_]
228+
if (!Pattern.compile("[\\w-]+").matcher(resolvedSnapshotname).matches())
229+
{
230+
throw new IllegalArgumentException("Snapshot name contains illegal characters: " + resolvedSnapshotname);
231+
}
232+
}
233+
234+
private void validateTTL(boolean ephemeral, DurationSpec.IntSecondsBound ttl)
235+
{
196236
if (ttl != null)
197237
{
198238
int minAllowedTtlSecs = CassandraRelevantProperties.SNAPSHOT_MIN_ALLOWED_TTL_SECONDS.getInt();
@@ -202,12 +242,8 @@ public SnapshotOptions build()
202242

203243
if (ephemeral && ttl != null)
204244
throw new IllegalStateException(format("can not take ephemeral snapshot (%s) while ttl is specified too", tag));
205-
206-
if (rateLimiter == null)
207-
rateLimiter = DatabaseDescriptor.getSnapshotRateLimiter();
208-
209-
return new SnapshotOptions(this);
210245
}
246+
211247
}
212248

213249
@Override

src/java/org/apache/cassandra/service/snapshot/TakeSnapshotTask.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public Map<ColumnFamilyStore, TableSnapshot> getSnapshotsToCreate()
8888
else
8989
creationTime = options.creationTime;
9090

91-
snapshotName = options.getSnapshotName(creationTime);
91+
snapshotName = SnapshotOptions.getSnapshotName(options.type, options.tag, creationTime);
9292

9393
Set<ColumnFamilyStore> entitiesForSnapshot = options.cfs == null ? parseEntitiesForSnapshot(options.entities) : Set.of(options.cfs);
9494

test/unit/org/apache/cassandra/service/snapshot/SnapshotOptionsTest.java

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,61 @@
2626

2727
import org.junit.Test;
2828

29+
import org.apache.cassandra.io.util.File;
30+
2931
import static java.lang.String.format;
32+
import static org.apache.cassandra.service.snapshot.SnapshotType.USER;
33+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3034
import static org.junit.Assert.assertEquals;
3135

3236
public class SnapshotOptionsTest
3337
{
38+
@Test
39+
public void testSnapshotNameValidation()
40+
{
41+
validate("atag", USER);
42+
validate("a-tag", USER);
43+
validate("a_tag", USER);
44+
validate("a_tag" + Instant.now().toEpochMilli(), USER);
45+
validate("a_tag_1and_something2-more", USER);
46+
validate("a".repeat(255), USER);
47+
48+
assertThatThrownBy(() -> validate("a".repeat(256), USER))
49+
.isInstanceOf(IllegalArgumentException.class)
50+
.hasMessage("Snapshot name must not be more than 255 characters long for " +
51+
"resolved snapshot name (got 256 characters for \"" + "a".repeat(256) + "\")");
52+
53+
// this would append timestamp + type which would violate < 255 when tag is 250 chars long only
54+
assertThatThrownBy(() -> validate("a".repeat(250), SnapshotType.UPGRADE))
55+
.isInstanceOf(IllegalArgumentException.class)
56+
.hasMessageContaining("Snapshot name must not be more than 255 characters long");
57+
58+
assertThatThrownBy(() -> validate('a' + File.pathSeparator() + "tag", USER))
59+
.isInstanceOf(IllegalArgumentException.class)
60+
.hasMessage("Snapshot name contains illegal characters: a/tag");
61+
62+
assertThatThrownBy(() -> validate("a..tag", USER))
63+
.isInstanceOf(IllegalArgumentException.class)
64+
.hasMessage("Snapshot name contains illegal characters: a..tag");
65+
}
66+
67+
private void validate(String tag, SnapshotType type)
68+
{
69+
new SnapshotOptions.Builder(tag, type, s -> true, "ks.tb").rateLimiter(RateLimiter.create(1)).build();
70+
}
71+
3472
@Test
3573
public void testSnapshotName()
3674
{
37-
List<SnapshotType> sameNameTypes = List.of(SnapshotType.DIAGNOSTICS, SnapshotType.REPAIR, SnapshotType.USER);
75+
List<SnapshotType> sameNameTypes = List.of(SnapshotType.DIAGNOSTICS, SnapshotType.REPAIR, USER);
3876

3977
for (SnapshotType type : sameNameTypes)
4078
{
4179
SnapshotOptions options = SnapshotOptions.systemSnapshot("a_name", type, "ks.tb")
4280
.rateLimiter(RateLimiter.create(5))
4381
.build();
4482

45-
String snapshotName = options.getSnapshotName(Instant.now());
83+
String snapshotName = SnapshotOptions.getSnapshotName(type, options.tag, Instant.now());
4684
assertEquals("a_name", snapshotName);
4785
}
4886

@@ -57,7 +95,7 @@ public void testSnapshotName()
5795

5896
Instant now = Instant.now();
5997

60-
String snapshotName = options.getSnapshotName(now);
98+
String snapshotName = SnapshotOptions.getSnapshotName(options.type, options.tag, now);
6199

62100
assertEquals(format("%d-%s-%s", now.toEpochMilli(), type.label, "a_name"), snapshotName);
63101
}

0 commit comments

Comments
 (0)