Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.sasanlabs.service.vulnerability.idor;

import java.util.Base64;
import java.util.List;
import org.sasanlabs.internal.utility.LevelConstants;
import org.sasanlabs.internal.utility.Variant;
Expand Down Expand Up @@ -77,15 +76,19 @@ public ResponseEntity<GenericVulnerabilityResponseBean<Object>> level1(
String actualToken = cookieToken;
try {
if (actualToken != null) {
idorLoginService.decodeToken(actualToken);
if (id != null) {
User profile = fetchUserById(id);
if (profile == null) {
return response(USER_NOT_FOUND, false);
}
return response(profile, true);
User decodedUser = idorLoginService.decodeToken(actualToken);
int tokenUserId = decodedUser.getUserId();
int requestedId = id != null ? id : tokenUserId;

if (requestedId != tokenUserId) {
return response(ACCESS_DENIED_INSUFFICIENT, false);
}

User profile = fetchUserById(requestedId);
if (profile == null) {
return response(USER_NOT_FOUND, false);
}
return response(USER_NOT_FOUND, false);
return response(profile, true);
}

return response(PROVIDE_LOGIN_OR_TOKEN, false);
Expand Down Expand Up @@ -113,11 +116,15 @@ public ResponseEntity<GenericVulnerabilityResponseBean<Object>> level2(
@CookieValue(value = COOKIE_TOKEN_LEVEL_2, required = false) String cookieToken,
@CookieValue(value = COOKIE_USER_ID_LEVEL_2, required = false) Integer loggedInUser) {

// Note: loggedInUser is an unsigned, attacker-controllable cookie left in the request
// model for backward compatibility with the login flow, but the user identity used for
// the lookup below is always taken from the signed token, never from this cookie -
// otherwise an attacker could simply edit userId_level2 to view another user's profile.
String actualToken = cookieToken;
try {
if (actualToken != null && loggedInUser != null) {
idorLoginService.decodeToken(actualToken);
User profile = fetchUserById(loggedInUser);
if (actualToken != null) {
User decodedUser = idorLoginService.decodeToken(actualToken);
User profile = fetchUserById(decodedUser.getUserId());
if (profile == null) {
return response(USER_NOT_FOUND, false);
}
Expand Down Expand Up @@ -150,12 +157,17 @@ public ResponseEntity<GenericVulnerabilityResponseBean<Object>> level3(
@CookieValue(value = COOKIE_ROLE_LEVEL_3, required = false) String cookieRole,
@RequestParam(required = false) Integer id) {

// Note: cookieRole is an unsigned, attacker-controllable cookie. It is accepted as a
// request parameter for backward compatibility but must never be trusted as the
// authoritative role - only the role embedded in the signed JWT is used for the
// authorization decision below, otherwise an attacker could set role_level3=ADMIN to
// impersonate an administrator.
String actualToken = cookieToken;
try {
if (actualToken != null) {
User decodedUser = idorLoginService.decodeToken(actualToken);
int tokenUserId = decodedUser.getUserId();
String role = cookieRole != null ? cookieRole : decodedUser.getRole();
String role = decodedUser.getRole();

if (id == null) {
id = tokenUserId;
Expand Down Expand Up @@ -199,12 +211,16 @@ public ResponseEntity<GenericVulnerabilityResponseBean<Object>> level4(
@CookieValue(value = COOKIE_ROLE_LEVEL_4, required = false) String cookieRole,
@RequestParam(required = false) Integer id) {

// Note: cookieRole here is merely base64-encoded, not signed - encoding is not
// encryption/authentication, so it is just as forgeable as the plaintext cookie in
// level 3. As with level 3, the role used for the authorization decision below always
// comes from the signed JWT, never from this cookie.
String actualToken = cookieToken;
try {
if (actualToken != null) {
User decodedUser = idorLoginService.decodeToken(actualToken);
int tokenUserId = decodedUser.getUserId();
String role = cookieRole != null ? decodeBase64(cookieRole) : decodedUser.getRole();
String role = decodedUser.getRole();

if (id == null) {
id = tokenUserId;
Expand Down Expand Up @@ -304,14 +320,6 @@ private List<User> fetchAllUsers() {
rs.getString("role")));
}

private String decodeBase64(String encodedId) {
try {
return new String(Base64.getUrlDecoder().decode(encodedId));
} catch (IllegalArgumentException e) {
return null;
}
}

private ResponseEntity<GenericVulnerabilityResponseBean<Object>> response(
Object content, boolean isValid) {
return new ResponseEntity<>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,32 @@ private ResponseEntity<GenericVulnerabilityResponseBean<String>> getJWTResponseB
genericVulnerabilityResponseBean, headers, HttpStatus.OK);
}

// Previously this endpoint accepted the JWT to be verified as a "JWT" query parameter
// (?JWT=...), which means the token ends up in the URL: it gets written to server access
// logs, browser history, the Referer header of any subsequent cross-origin request, and any
// proxy/CDN logs along the way. A bearer token is a credential and must never travel in the
// URL; it belongs in a header (or the request body) instead, so it now reads the token from
// the standard Authorization header, matching the pattern already used by the other
// header-based levels in this class.
@AttackVector(
vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT,
description = "JWT_URL_EXPOSING_SECURE_INFORMATION")
@VulnerableAppRequestMapping(
value = LevelConstants.LEVEL_1,
htmlTemplate = "LEVEL_1/JWT_Level1")
public ResponseEntity<GenericVulnerabilityResponseBean<String>>
getVulnerablePayloadLevelUnsecure(@RequestParam Map<String, String> queryParams)
getVulnerablePayloadLevelUnsecure(RequestEntity<Void> requestEntity)
throws UnsupportedEncodingException, ServiceApplicationException {
Optional<SymmetricAlgorithmKey> symmetricAlgorithmKey =
jwtAlgorithmKMS.getSymmetricAlgorithmKey(
JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH);
LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get());
String token = queryParams.get(JWT);
List<String> authorizationHeaders =
requestEntity.getHeaders().get(HttpHeaders.AUTHORIZATION);
String token =
(authorizationHeaders != null && !authorizationHeaders.isEmpty())
? authorizationHeaders.get(0)
: null;
if (token != null) {
boolean isValid =
jwtValidator.customHMACValidator(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,61 @@ private ResponseEntity<?> getURLRedirectionResponseEntity(
return new ResponseEntity<>(HttpStatus.OK);
}

/**
* Browsers silently strip ASCII control characters (e.g. tab, newline) from a URL before
* parsing it, and normalize backslashes to forward slashes. A naive {@code startsWith}
* blacklist that only inspects the raw, un-normalized string can therefore be bypassed with
* payloads such as {@code "/\t/evil.com"} (control char hides a leading "//") or {@code
* "\/\/evil.com"} (backslashes stand in for the forward slashes). This helper normalizes the
* candidate the same way a browser would before applying the scheme/authority checks, so
* those bypasses collapse back into the already-blocked "//" / scheme-prefixed cases.
*/
private boolean isNotProtocolRelativeOrAbsolute(String url) {
String normalized = url.replace('\\', '/');
for (int i = 0; i < normalized.length(); i++) {
if (normalized.charAt(i) <= ' ') {
// any embedded control/whitespace character is itself suspicious and is never
// part of a legitimate relative path we generate
return false;
}
}
return !normalized.startsWith(FrameworkConstants.HTTP)
&& !normalized.startsWith(FrameworkConstants.HTTPS)
&& !normalized.startsWith(FrameworkConstants.WWW)
&& !normalized.startsWith("//")
&& !normalized.startsWith(NULL_BYTE_CHARACTER);
}

/**
* Builds a same-origin redirect path out of untrusted user input. Unlike naively concatenating
* {@code authority + urlToRedirect}, this guarantees exactly one leading slash and strips
* characters ("@", backslash, control chars) that could otherwise be abused to smuggle
* userinfo (e.g. {@code http://trusted.com@evil.com}) or a protocol-relative host into the
* resulting Location header.
*/
private String toSameOriginPath(String urlToRedirect) {
if (urlToRedirect == null) {
return "/";
}
String normalized = urlToRedirect.replace('\\', '/');
StringBuilder sanitized = new StringBuilder();
for (int i = 0; i < normalized.length(); i++) {
char c = normalized.charAt(i);
if (c <= ' ' || c == '@') {
continue;
}
sanitized.append(c);
}
String result = sanitized.toString();
while (result.startsWith("//")) {
result = result.substring(1);
}
if (!result.startsWith("/")) {
result = "/" + result;
}
return result;
}

@AttackVector(
vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE},
description = "OPEN_REDIRECT_QUERY_PARAM_DIRECTLY_ADD_TO_LOCATION_HEADER")
Expand Down Expand Up @@ -225,12 +280,7 @@ public ResponseEntity<?> getVulnerablePayloadLevel5(
return this.getURLRedirectionResponseEntity(
urlToRedirect,
(url) ->
(!url.startsWith(FrameworkConstants.HTTP)
&& !url.startsWith(FrameworkConstants.HTTPS)
&& !url.startsWith("//")
&& !url.startsWith(FrameworkConstants.WWW)
&& !url.startsWith(NULL_BYTE_CHARACTER)
&& (url.length() > 0 && url.charAt(0) > 20))
(url.length() > 0 && this.isNotProtocolRelativeOrAbsolute(url))
|| requestUrl.getAuthority().equals(url));
}

Expand Down Expand Up @@ -262,7 +312,11 @@ public ResponseEntity<?> getVulnerablePayloadLevel6(
headerParam.put(LOCATION_HEADER_KEY, new ArrayList<>());
headerParam
.get(LOCATION_HEADER_KEY)
.add(requestUrl.getProtocol() + "://" + requestUrl.getAuthority() + urlToRedirect);
.add(
requestUrl.getProtocol()
+ "://"
+ requestUrl.getAuthority()
+ this.toSameOriginPath(urlToRedirect));
return new ResponseEntity<>(headerParam, HttpStatus.FOUND);
}

Expand Down Expand Up @@ -290,17 +344,13 @@ public ResponseEntity<?> getVulnerablePayloadLevel7(
MultiValueMap<String, String> headerParam = new org.springframework.http.HttpHeaders();
URL requestUrl = new URL(requestEntity.getUrl().toString());
headerParam.put(LOCATION_HEADER_KEY, new ArrayList<>());
if (urlToRedirect.startsWith("/")) {
urlToRedirect = urlToRedirect.substring(1);
}
headerParam
.get(LOCATION_HEADER_KEY)
.add(
requestUrl.getProtocol()
+ "://"
+ requestUrl.getAuthority()
+ "/"
+ urlToRedirect);
+ this.toSameOriginPath(urlToRedirect));
return new ResponseEntity<>(headerParam, HttpStatus.FOUND);
}

Expand Down Expand Up @@ -334,8 +384,14 @@ public ResponseEntity<?> getVulnerablePayloadLevel8(
value = LevelConstants.LEVEL_9,
htmlTemplate = "LEVEL_9/Http3xxStatusCodeBasedInjection")
public ResponseEntity<?> getVulnerablePayloadLevel9(
@RequestParam(RETURN_TO) String urlToRedirect) {
return this.getURLRedirectionResponseEntity(urlToRedirect, (url) -> true);
RequestEntity<String> requestEntity, @RequestParam(RETURN_TO) String urlToRedirect)
throws MalformedURLException {
URL requestUrl = new URL(requestEntity.getUrl().toString());
return this.getURLRedirectionResponseEntity(
urlToRedirect,
(url) ->
(url.length() > 0 && this.isNotProtocolRelativeOrAbsolute(url))
|| requestUrl.getAuthority().equals(url));
}

// Payloads: any URL e.g. /VulnerableApp/phishing/fake-login.html
Expand All @@ -359,8 +415,14 @@ public ResponseEntity<?> getVulnerablePayloadLevel9(
value = LevelConstants.LEVEL_10,
htmlTemplate = "LEVEL_10/Http3xxStatusCodeBasedInjection")
public ResponseEntity<?> getVulnerablePayloadLevel10(
@RequestParam(RETURN_TO) String urlToRedirect) {
return this.getURLRedirectionResponseEntity(urlToRedirect, (url) -> true);
RequestEntity<String> requestEntity, @RequestParam(RETURN_TO) String urlToRedirect)
throws MalformedURLException {
URL requestUrl = new URL(requestEntity.getUrl().toString());
return this.getURLRedirectionResponseEntity(
urlToRedirect,
(url) ->
(url.length() > 0 && this.isNotProtocolRelativeOrAbsolute(url))
|| requestUrl.getAuthority().equals(url));
}

@AttackVector(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ addingEventListenerToFetchTokenButton();
function addingEventListenerToVerifyToken() {
document.getElementById("verifyToken").addEventListener("click", function () {
let url = getUrlForVulnerabilityLevel();
url = url + "?JWT=" + document.getElementById("jwt").value;
console.log(url);
console.log(document.getElementById("jwt").value);
doGetAjaxCall(updateUIWithVerifyResponse, url, true);
// The JWT is sent as an Authorization header rather than a URL query
// parameter so it is never written to browser history, server access
// logs, or a Referer header.
doGetAjaxCall(updateUIWithVerifyResponse, url, true, {
Authorization: document.getElementById("jwt").value,
});
});
}
addingEventListenerToVerifyToken();
Expand Down
Loading
Loading