Skip to content

Commit 7fd33d9

Browse files
committed
fix(dotauth): address code review findings across OAuth/OIDC layer
- Cache RemoteJWKSet instances by jwksUri to avoid per-validation JWKS fetches - Bound ROLE_SYNC_LOCKS with Guava Cache (maxSize 10k) to prevent unbounded growth - Swap auth check order in saveConfig so unauthenticated callers get 401 before 400 - Add requireAdmin gate to saveHeadlessConfig and clearHeadlessConfig - Default to deny-all CORS when allowedOrigins is empty on the exchange endpoint - Replace findAll with search API in listSites to skip archived/system hosts at query level - Use sendRedirect instead of raw setHeader for provider logout redirect - Add null guard on config in applyExtraRoles to prevent potential NPE
1 parent 2e250c9 commit 7fd33d9

6 files changed

Lines changed: 49 additions & 33 deletions

File tree

core-web/libs/dotcms-models/src/lib/dot-auth.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface DotAuthConfigValues {
5959
extraRoles?: string;
6060
buildRolesStrategy?: string;
6161
callbackUrl?: string;
62+
autoProvision?: boolean;
6263
}
6364

6465
export type DotAuthSignatureValidation = 'none' | 'response' | 'assertion' | 'responseandassertion';

dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResource.java

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -215,23 +215,32 @@ public Response exchange(@Context final HttpServletRequest request,
215215
}
216216
final OAuthAppConfig config = cfgOpt.get();
217217

218-
// CORS origin check: reject requests from unlisted browser origins
218+
// CORS origin check: reject requests from unlisted browser origins.
219+
// When no allowedOrigins are configured, browser-originated requests are
220+
// denied outright — the safe default for a token-exchange endpoint.
221+
// Non-browser callers (no Origin header) pass through unaffected.
219222
final List<String> allowedOrigins = parseJsonStringList(config.allowedOriginsJson);
220-
if (!allowedOrigins.isEmpty()) {
221-
final String origin = request.getHeader("Origin");
222-
if (UtilMethods.isSet(origin) && !allowedOrigins.contains(origin)) {
223+
final String origin = request.getHeader("Origin");
224+
if (UtilMethods.isSet(origin)) {
225+
if (allowedOrigins.isEmpty()) {
226+
SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class,
227+
"OAuth exchange rejected: no allowedOrigins configured; origin '"
228+
+ origin + "' from " + request.getRemoteAddr());
229+
return Response.status(Response.Status.FORBIDDEN)
230+
.entity(new ResponseEntityView<>(
231+
"allowedOrigins must be configured for browser-based token exchange"))
232+
.build();
233+
}
234+
if (!allowedOrigins.contains(origin)) {
223235
SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class,
224236
"OAuth exchange rejected: origin '" + origin
225237
+ "' not in allowed origins from " + request.getRemoteAddr());
226238
return Response.status(Response.Status.FORBIDDEN)
227239
.entity(new ResponseEntityView<>("Origin not allowed"))
228240
.build();
229241
}
230-
// Set CORS headers for allowed origins
231-
if (UtilMethods.isSet(origin)) {
232-
response.setHeader("Access-Control-Allow-Origin", origin);
233-
response.setHeader("Access-Control-Allow-Credentials", "true");
234-
}
242+
response.setHeader("Access-Control-Allow-Origin", origin);
243+
response.setHeader("Access-Control-Allow-Credentials", "true");
235244
}
236245

237246
if (!config.isOidc()) {

dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthResource.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,12 @@ public final Response listSites(@Context final HttpServletRequest request,
172172
final boolean systemHeadless = systemKeys.contains(
173173
DotAuthConstants.HEADLESS_APP_KEY.toLowerCase());
174174

175-
final List<Host> allHosts = APILocator.getHostAPI().findAll(user, false);
175+
// Use the paginated search API instead of the deprecated findAll.
176+
// An empty filter with limit <= 0 returns all live, non-system hosts.
177+
final List<Host> allHosts = APILocator.getHostAPI()
178+
.search("", false, false, 0, 0, user, false);
176179
final List<DotAuthSitesView.SiteRowView> rows = new ArrayList<>();
177180
for (final Host host : allHosts) {
178-
if (host == null || host.isSystemHost() || host.isArchived()) {
179-
continue;
180-
}
181181
final Set<String> hostKeys = appsByHost
182182
.getOrDefault(host.getIdentifier().toLowerCase(), Set.of());
183183
final Optional<DotAuthProtocol> hostProtocol = detectProtocol(hostKeys);
@@ -314,8 +314,8 @@ public final Response saveConfig(@Context final HttpServletRequest request,
314314
if (form == null) {
315315
throw new IllegalArgumentException("Body required");
316316
}
317-
form.checkValid();
318317
final User user = initUser(request, response);
318+
form.checkValid();
319319
final Host host = resolveHost(hostId, user);
320320

321321
// DotAuthConfigForm#constructor already defaults protocol to OAUTH when null,
@@ -427,6 +427,7 @@ public final Response saveHeadlessConfig(@Context final HttpServletRequest reque
427427
throw new IllegalArgumentException("Body required");
428428
}
429429
final User user = initUser(request, response);
430+
requireAdmin(user);
430431
final Host systemHost = APILocator.systemHost();
431432
appsAPI.saveSecrets(headlessHelper.buildSecrets(form), systemHost, user);
432433
SecurityLogger.logInfo(DotAuthResource.class,
@@ -456,6 +457,7 @@ public final Response clearHeadlessConfig(@Context final HttpServletRequest requ
456457
@Context final HttpServletResponse response) {
457458
try {
458459
final User user = initUser(request, response);
460+
requireAdmin(user);
459461
final Host systemHost = APILocator.systemHost();
460462
appsAPI.deleteSecrets(DotAuthConstants.HEADLESS_APP_KEY, systemHost, user);
461463
SecurityLogger.logInfo(DotAuthResource.class,

dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthHelper.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.dotmarketing.exception.DotDataException;
1010
import com.dotmarketing.exception.DotRuntimeException;
1111
import com.dotmarketing.exception.DotSecurityException;
12+
import com.google.common.cache.Cache;
13+
import com.google.common.cache.CacheBuilder;
1214
import com.dotmarketing.util.Config;
1315
import com.dotmarketing.util.Logger;
1416
import com.dotmarketing.util.SecurityLogger;
@@ -26,7 +28,7 @@
2628
import java.util.LinkedHashSet;
2729
import java.util.List;
2830
import java.util.Map;
29-
import java.util.concurrent.ConcurrentHashMap;
31+
import java.util.concurrent.ExecutionException;
3032
import javax.servlet.http.HttpServletRequest;
3133
import javax.servlet.http.HttpServletResponse;
3234
import javax.servlet.http.HttpSession;
@@ -54,7 +56,8 @@ public class OAuthHelper {
5456
* case is a redundant reapply of the same roles, which is idempotent. That
5557
* is an accepted cost versus the complexity of a distributed lock.
5658
*/
57-
private static final ConcurrentHashMap<String, Object> ROLE_SYNC_LOCKS = new ConcurrentHashMap<>();
59+
private static final Cache<String, Object> ROLE_SYNC_LOCKS =
60+
CacheBuilder.newBuilder().maximumSize(10_000).build();
5861

5962
/**
6063
* Per-login role-sync strategy, mirroring {@code SAMLHelper}'s {@code build.roles}
@@ -319,7 +322,12 @@ private void applyBuildRolesStrategy(final User user,
319322
// same user never observe the "wiped but not yet reapplied" intermediate state.
320323
// Intrinsic-monitor lock on a per-user sentinel is enough — the block runs
321324
// DB-bound work for well under a second and we hold nothing else inside it.
322-
final Object userLock = ROLE_SYNC_LOCKS.computeIfAbsent(user.getUserId(), id -> new Object());
325+
final Object userLock;
326+
try {
327+
userLock = ROLE_SYNC_LOCKS.get(user.getUserId(), Object::new);
328+
} catch (final ExecutionException e) {
329+
throw new DotRuntimeException("Failed to acquire role sync lock", e);
330+
}
323331
synchronized (userLock) {
324332
// Remove all existing roles before reapplying, unless the strategy is STATICADD
325333
// (additive / legacy behavior). Matches SAMLHelper.addRoles exactly.
@@ -427,7 +435,7 @@ private void addRoleIfPresent(final User user, final Role role) {
427435
}
428436

429437
private void applyExtraRoles(final User user, final OAuthAppConfig config) {
430-
if (config.extraRoles == null) {
438+
if (config == null || config.extraRoles == null) {
431439
return;
432440
}
433441
for (final String roleKey : config.extraRoles) {

dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptor.java

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ private Result handleLogout(final HttpServletRequest request,
324324
*/
325325
private Result doCoreLogout(final HttpServletRequest request,
326326
final HttpServletResponse response,
327-
final String providerLogoutUrl) {
327+
final String providerLogoutUrl) throws IOException {
328328
final User user = PortalUtil.getUser(request);
329329
Try.run(() -> APILocator.getLoginServiceAPI().doActionLogout(request, response))
330330
.onFailure(e -> Logger.warn(this, "doActionLogout failed: " + e.getMessage()));
@@ -334,13 +334,7 @@ private Result doCoreLogout(final HttpServletRequest request,
334334
+ ") has logged out via OAuth from IP: " + request.getRemoteAddr());
335335
}
336336
if (!response.isCommitted()) {
337-
if (UtilMethods.isSet(providerLogoutUrl)) {
338-
response.setStatus(HttpServletResponse.SC_FOUND);
339-
response.setHeader("Location", providerLogoutUrl);
340-
} else {
341-
response.setStatus(HttpServletResponse.SC_FOUND);
342-
response.setHeader("Location", "/");
343-
}
337+
response.sendRedirect(UtilMethods.isSet(providerLogoutUrl) ? providerLogoutUrl : "/");
344338
}
345339
return Result.SKIP_NO_CHAIN;
346340
}

dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OIDCProvider.java

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ public class OIDCProvider implements OAuthProvider {
5959
*/
6060
private static final ConcurrentHashMap<String, CachedDiscovery> DISCOVERY_CACHE = new ConcurrentHashMap<>();
6161

62+
private static final ConcurrentHashMap<String, RemoteJWKSet<SecurityContext>> JWKS_CACHE = new ConcurrentHashMap<>();
63+
6264
/**
6365
* Safe {@code id_token} signing algorithms. Asymmetric only (RSA or EC) — symmetric
6466
* {@code HS*} algs rely on a shared secret, and {@code none} is an explicit attacker
@@ -362,13 +364,13 @@ public Map<String, Object> validateIdTokenAndExtractClaims(final String idToken,
362364
}
363365
try {
364366
final SignedJWT jwt = SignedJWT.parse(idToken);
365-
final URL jwksUrl;
366-
try {
367-
jwksUrl = new URL(jwksUri);
368-
} catch (final MalformedURLException e) {
369-
throw new DotRuntimeException("OIDC jwks_uri is malformed: " + jwksUri, e);
370-
}
371-
final JWKSource<SecurityContext> keySource = new RemoteJWKSet<>(jwksUrl);
367+
final JWKSource<SecurityContext> keySource = JWKS_CACHE.computeIfAbsent(jwksUri, uri -> {
368+
try {
369+
return new RemoteJWKSet<>(new URL(uri));
370+
} catch (final MalformedURLException e) {
371+
throw new DotRuntimeException("OIDC jwks_uri is malformed: " + uri, e);
372+
}
373+
});
372374
final JWSAlgorithm tokenAlg = jwt.getHeader().getAlgorithm() == null
373375
? null
374376
: JWSAlgorithm.parse(jwt.getHeader().getAlgorithm().getName());

0 commit comments

Comments
 (0)