From ee64fa1938ff8e9324c0f2ae96a13d0c9b0006f4 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 12:14:30 +0300 Subject: [PATCH] Validate mobile app callback url scheme --- .../model/token/OAuth2AppTokenFactory.java | 12 +++ .../token/OAuth2AppTokenFactoryTest.java | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java index a353aac86b..4a9b18cafd 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java @@ -29,7 +29,10 @@ import org.thingsboard.server.common.data.StringUtils; import java.util.Base64; import java.util.Date; +import java.util.Locale; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; @Component @Slf4j @@ -39,6 +42,9 @@ public class OAuth2AppTokenFactory { private static final long MAX_EXPIRATION_TIME_DIFF_MS = TimeUnit.MINUTES.toMillis(5); + private static final Pattern CALLBACK_URL_SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*"); + private static final Set FORBIDDEN_CALLBACK_URL_SCHEMES = Set.of("http", "https", "javascript", "data", "file", "vbscript"); + public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) { Jws jwsClaims; try { @@ -65,6 +71,12 @@ public class OAuth2AppTokenFactory { if (StringUtils.isEmpty(callbackUrlScheme)) { throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme"); } + // the redirect carrying the access token is built as callbackUrlScheme + ":", so only a mobile app scheme + // may pass: a web scheme would send the token to whatever host follows it + if (!CALLBACK_URL_SCHEME_PATTERN.matcher(callbackUrlScheme).matches() + || FORBIDDEN_CALLBACK_URL_SCHEMES.contains(callbackUrlScheme.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Application token has invalid callbackUrlScheme"); + } return callbackUrlScheme; } diff --git a/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java new file mode 100644 index 0000000000..ec4da607f8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.security.model.token; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class OAuth2AppTokenFactoryTest { + + private static final String APP_PACKAGE = "org.thingsboard.demo.app"; + private static final byte[] KEY_BYTES = "yjNyylzT1TmiVE2jV3YTnUpZzwLLLdPDJKmhLNyXDPnLtVCLcJIjIGmDPKHNoDMK".getBytes(); + + private final OAuth2AppTokenFactory tokenFactory = new OAuth2AppTokenFactory(); + + @Test + public void testMobileAppSchemeIsAccepted() { + assertThat(validate("tb-mobile.app1")).isEqualTo("tb-mobile.app1"); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://evil.com", + "http://evil.com", + "https", + "HTTPS", + "javascript", + "data", + "//evil.com", + "tbmobile/evil.com", + "tbmobile:evil.com", + "tbmobile evil", + "1tbmobile", + "tbmobile@evil.com" + }) + public void testInvalidCallbackUrlSchemeIsRejected(String callbackUrlScheme) { + assertThatThrownBy(() -> validate(callbackUrlScheme)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("callbackUrlScheme"); + } + + private String validate(String callbackUrlScheme) { + SecretKey key = Keys.hmacShaKeyFor(KEY_BYTES); + String appToken = Jwts.builder() + .issuer(APP_PACKAGE) + .expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) + .claim("callbackUrlScheme", callbackUrlScheme) + .signWith(key) + .compact(); + return tokenFactory.validateTokenAndGetCallbackUrlScheme(APP_PACKAGE, appToken, Base64.getEncoder().encodeToString(KEY_BYTES)); + } + +}