Skip to content

Commit 74664a6

Browse files
committed
feat: add supertokens_min_cdi_version config
Rejects requests using a CDI version below a configured minimum (400). Useful for refusing older CDI versions entirely. Validated as a supported version and <= max.
1 parent a810bd1 commit 74664a6

6 files changed

Lines changed: 198 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

1010
- Security: app-specific management APIs now reject non-public/unknown tenant paths on every CDI version (the public-tenant guard was only enforced for CDI >= 5.0, letting `/sometenant/recipe/...` on CDI 3.0-4.0 bypass it)
1111
- Security: the api-key and IP-allow/deny checks no longer fail open when the request's tenant does not exist; they resolve against the app's public tenant instead
12+
- Adds `supertokens_min_cdi_version` config to reject requests using a CDI version below a configured minimum
1213
- Adds CDI 5.5: webauthn sign-in options are single-use — consumed atomically on successful sign in (replay returns `OPTIONS_NOT_FOUND_ERROR`); requests on CDI <= 5.4 are unaffected. Requires SDKs on CDI 5.5 to verify each assertion exactly once (see supertokens-core#1195)
1314
- Adds `removeOptions_Transaction` to `WebAuthNSQLStorage` (plugin-interface addition; needs a plugin-interface version bump at release)
1415

config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ core_config_version: 0
148148
# supertokens_max_cdi_version:
149149

150150

151+
# (DIFFERENT_ACROSS_APPS | OPTIONAL | Default: null). The minimum CDI version that the core will accept. Requests
152+
# using a CDI version lower than this are rejected with a 400. When set to null, all CDI versions supported by the
153+
# core are accepted.
154+
# supertokens_min_cdi_version:
155+
156+
151157
# (OPTIONAL | Default: null) string value. If specified, the supertokens service will only load the specified CUD even
152158
# if there are more CUDs in the database and block all other CUDs from being used from this instance.
153159
# supertokens_saas_load_only_cud:

devConfig.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ bcrypt_log_rounds: 4
148148
# CDI.
149149
# supertokens_max_cdi_version:
150150

151+
# (DIFFERENT_ACROSS_APPS | OPTIONAL | Default: null). The minimum CDI version that the core will accept. Requests
152+
# using a CDI version lower than this are rejected with a 400. When set to null, all CDI versions supported by the
153+
# core are accepted.
154+
# supertokens_min_cdi_version:
155+
151156
# (OPTIONAL | Default: null) string value. If specified, the supertokens service will only load the specified CUD even
152157
# if there are more CUDs in the database and block all other CUDs from being used from this instance.
153158
# supertokens_saas_load_only_cud:

src/main/java/io/supertokens/config/CoreConfig.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,17 @@ public class CoreConfig {
370370
"null)")
371371
private String supertokens_max_cdi_version = null;
372372

373+
@EnvName("SUPERTOKENS_MIN_CDI_VERSION")
374+
@NotConflictingInApp
375+
@JsonProperty
376+
@HideFromDashboard
377+
@ConfigDescription(
378+
"The minimum CDI version that the core will accept. Requests using a CDI version lower than this are " +
379+
"rejected with a 400. Useful for refusing older CDI versions entirely (for example to require " +
380+
"the tenant-scope enforcement that applies from CDI 5.0). When set to null, all CDI versions " +
381+
"supported by the core are accepted. (Default: null)")
382+
private String supertokens_min_cdi_version = null;
383+
373384
@EnvName("SUPERTOKENS_SAAS_LOAD_ONLY_CUD")
374385
@ConfigYamlOnly
375386
@JsonProperty
@@ -964,6 +975,23 @@ void normalizeAndValidate(Main main, boolean includeConfigFilePath) throws Inval
964975
}
965976
}
966977

978+
if (supertokens_min_cdi_version != null) {
979+
SemVer minVersion;
980+
try {
981+
minVersion = new SemVer(supertokens_min_cdi_version);
982+
} catch (IllegalArgumentException e) {
983+
throw new InvalidConfigException("supertokens_min_cdi_version is not a valid semantic version");
984+
}
985+
if (!WebserverAPI.supportedVersions.contains(minVersion)) {
986+
throw new InvalidConfigException("supertokens_min_cdi_version is not a supported version");
987+
}
988+
if (supertokens_max_cdi_version != null
989+
&& minVersion.greaterThan(new SemVer(supertokens_max_cdi_version))) {
990+
throw new InvalidConfigException(
991+
"supertokens_min_cdi_version cannot be greater than supertokens_max_cdi_version");
992+
}
993+
}
994+
967995
if (bulk_migration_parallelism < 1) {
968996
throw new InvalidConfigException("Provided bulk_migration_parallelism must be >= 1");
969997
}
@@ -1292,6 +1320,10 @@ public String getMaxCDIVersion() {
12921320
return this.supertokens_max_cdi_version;
12931321
}
12941322

1323+
public String getMinCDIVersion() {
1324+
return this.supertokens_min_cdi_version;
1325+
}
1326+
12951327
private boolean isAnySet(List<String> configs){
12961328
for (String config : configs){
12971329
if(config!=null){

src/main/java/io/supertokens/webserver/WebserverAPI.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,20 @@ protected String getRIDFromRequest(HttpServletRequest req) {
617617
return req.getHeader("rId");
618618
}
619619

620+
public SemVer getMinCDIVersionForRequest(HttpServletRequest req) throws ServletException {
621+
String minCDIVersionStr = null;
622+
try {
623+
minCDIVersionStr = Config.getConfig(
624+
getAppIdentifierWithoutVerifying(req).getAsPublicTenantIdentifier(), main).getMinCDIVersion();
625+
} catch (TenantOrAppNotFoundException e) {
626+
// ignore missing app; there is no configured minimum to enforce
627+
}
628+
if (minCDIVersionStr != null) {
629+
return new SemVer(minCDIVersionStr);
630+
}
631+
return null;
632+
}
633+
620634
protected SemVer getVersionFromRequest(HttpServletRequest req) throws ServletException {
621635
SemVer maxCDIVersion = getLatestCDIVersionForRequest(req);
622636
String version = req.getHeader("cdi-version");
@@ -629,6 +643,13 @@ protected SemVer getVersionFromRequest(HttpServletRequest req) throws ServletExc
629643
new BadRequestException("cdi-version " + versionFromRequest + " not supported"));
630644
}
631645

646+
SemVer minCDIVersion = getMinCDIVersionForRequest(req);
647+
if (minCDIVersion != null && versionFromRequest.lesserThan(minCDIVersion)) {
648+
throw new ServletException(new BadRequestException(
649+
"cdi-version " + versionFromRequest + " is lower than the minimum allowed version "
650+
+ minCDIVersion));
651+
}
652+
632653
return versionFromRequest;
633654
}
634655

src/test/java/io/supertokens/test/CDIVersionTest.java

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,4 +470,137 @@ public void testAPIVersionsWhenMaxCDIVersionIsSet() throws Exception {
470470
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
471471
}
472472

473+
@Test
474+
public void testMinCDIVersionInvalidSemanticVersion() throws Exception {
475+
String[] args = {"../"};
476+
477+
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args, false);
478+
Utils.setValueInConfig("supertokens_min_cdi_version", "5.x");
479+
process.startProcess();
480+
481+
ProcessState.EventAndException state = process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE);
482+
assertNotNull(state);
483+
assertEquals("supertokens_min_cdi_version is not a valid semantic version",
484+
state.exception.getCause().getMessage());
485+
486+
process.kill();
487+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
488+
}
489+
490+
@Test
491+
public void testMinCDIVersionUnsupportedVersion() throws Exception {
492+
String[] args = {"../"};
493+
494+
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args, false);
495+
Utils.setValueInConfig("supertokens_min_cdi_version", "\"4.5\"");
496+
process.startProcess();
497+
498+
ProcessState.EventAndException state = process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE);
499+
assertNotNull(state);
500+
assertEquals("supertokens_min_cdi_version is not a supported version",
501+
state.exception.getCause().getMessage());
502+
503+
process.kill();
504+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
505+
}
506+
507+
@Test
508+
public void testMinCDIVersionCannotBeGreaterThanMaxCDIVersion() throws Exception {
509+
String[] args = {"../"};
510+
511+
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args, false);
512+
Utils.setValueInConfig("supertokens_max_cdi_version", "\"5.0\"");
513+
Utils.setValueInConfig("supertokens_min_cdi_version", "\"5.4\"");
514+
process.startProcess();
515+
516+
ProcessState.EventAndException state = process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE);
517+
assertNotNull(state);
518+
assertEquals("supertokens_min_cdi_version cannot be greater than supertokens_max_cdi_version",
519+
state.exception.getCause().getMessage());
520+
521+
process.kill();
522+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
523+
}
524+
525+
@Test
526+
public void testRequestBelowMinCDIVersionIsRejected() throws Exception {
527+
String[] args = {"../"};
528+
529+
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args, false);
530+
Utils.setValueInConfig("supertokens_min_cdi_version", "\"5.0\"");
531+
process.startProcess();
532+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
533+
534+
// versions in [3.0, 5.0) are rejected
535+
for (String belowMin : new String[]{"3.0", "3.1", "4.0"}) {
536+
try {
537+
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
538+
"http://localhost:3567/recipe/jwt/jwks", null, 1000, 1000, null, belowMin, "jwt");
539+
fail("cdi-version " + belowMin + " should have been rejected as below the minimum");
540+
} catch (HttpResponseException e) {
541+
assertEquals(400, e.statusCode);
542+
assertTrue(e.getMessage().contains(
543+
"cdi-version " + belowMin + " is lower than the minimum allowed version 5.0"));
544+
}
545+
}
546+
547+
// the minimum itself and versions above it are accepted
548+
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
549+
"http://localhost:3567/recipe/jwt/jwks", null, 1000, 1000, null, "5.0", "jwt");
550+
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
551+
"http://localhost:3567/recipe/jwt/jwks", null, 1000, 1000, null, "5.4", "jwt");
552+
553+
process.kill();
554+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
555+
}
556+
557+
@Test
558+
public void testWithoutMinCDIVersionOldVersionsAreAccepted() throws Exception {
559+
String[] args = {"../"};
560+
561+
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args, false);
562+
process.startProcess();
563+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
564+
565+
// control: no minimum configured, so an old CDI version is still accepted
566+
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
567+
"http://localhost:3567/recipe/jwt/jwks", null, 1000, 1000, null, "3.0", "jwt");
568+
569+
process.kill();
570+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
571+
}
572+
573+
@Test
574+
public void testMinCDIVersionBlocksGhostTenantOldCdiBypass() throws Exception {
575+
// Defense in depth for the ghost-tenant bypass: setting the minimum to 5.0 refuses the
576+
// vulnerable CDI window (3.0-4.0) outright, before the request reaches any handler.
577+
String[] args = {"../"};
578+
579+
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args, false);
580+
Utils.setValueInConfig("supertokens_min_cdi_version", "\"5.0\"");
581+
process.startProcess();
582+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
583+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
584+
return;
585+
}
586+
587+
JsonObject body = new JsonObject();
588+
body.addProperty("role", "ldvr-min-cdi");
589+
JsonArray permissions = new JsonArray();
590+
permissions.add("read");
591+
body.add("permissions", permissions);
592+
593+
try {
594+
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
595+
"http://localhost:3567/ghosttenant/recipe/role", body, 10000, 10000, null, "3.0", "userroles");
596+
fail("ghost-tenant create on CDI 3.0 must be rejected when min_cdi_version is 5.0");
597+
} catch (HttpResponseException e) {
598+
assertEquals(400, e.statusCode);
599+
assertTrue(e.getMessage().contains("is lower than the minimum allowed version 5.0"));
600+
}
601+
602+
process.kill();
603+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
604+
}
605+
473606
}

0 commit comments

Comments
 (0)