Skip to content
Merged
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
Expand Up @@ -57,12 +57,11 @@ public Result uploadFile(HttpServletRequest request,
@RequestParam("file") MultipartFile uploadedFile,
@RequestParam("fileUri") String fileUri) {
String storageToken = request.getHeader("Authorization");
if (storageToken != null) {
storageToken = storageToken.replaceAll("Bearer ", "");
} else {
storageToken = extractBearerToken(storageToken);
if (storageToken == null) {
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Invalid visit with no auth code");
}
if (!storageTokenManageService.validateAccessToken(storageToken)) {
if (!storageTokenManageService.validateAccessToken(storageToken, Const.FilePermission.WRITE, fileUri)) {
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, error access token for storage actions.");
}
if (!LogUtils.isLegalStr(fileUri, Const.RegexString.STORAGE_FILE_REL_PATH, false)) {
Expand Down Expand Up @@ -90,25 +89,22 @@ public void postDownloadFile(HttpServletRequest request,
HttpServletResponse response,
@RequestParam("fileUri") String fileUri) {
String storageToken = request.getHeader("Authorization");
if (storageToken != null) {
storageToken = storageToken.replaceAll("Bearer ", "");
} else {
storageToken = extractBearerToken(storageToken);
if (storageToken == null) {
throw new HydraLabRuntimeException(HttpStatus.UNAUTHORIZED.value(), "Invalid visit with no auth code");
}
if (!storageTokenManageService.validateAccessToken(storageToken)) {
boolean canRead = storageTokenManageService.validateAccessToken(
storageToken, Const.FilePermission.READ, fileUri);
boolean canWrite = storageTokenManageService.validateAccessToken(
storageToken, Const.FilePermission.WRITE, fileUri);
if (!canRead && !canWrite) {
throw new HydraLabRuntimeException(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, error access token for storage actions.");
}
if (!LogUtils.isLegalStr(fileUri, Const.RegexString.STORAGE_FILE_REL_PATH, false)) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid file path, file name should not include ';'!");
}

Path publicFolder = Paths.get(Const.LocalStorageURL.CENTER_LOCAL_STORAGE_ROOT).normalize().toAbsolutePath();
Path filePath = publicFolder.resolve(fileUri).normalize().toAbsolutePath();
if (!filePath.startsWith(publicFolder + File.separator)) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid file path");
}

File file = new File(Const.LocalStorageURL.CENTER_LOCAL_STORAGE_ROOT + fileUri);
File file = LocalStorageIOUtil.resolveFilePath(fileUri).toFile();
if (!file.exists()) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), String.format("File %s not exist!", fileUri));
}
Expand Down Expand Up @@ -136,17 +132,17 @@ public void getDownloadFile(HttpServletRequest request,
if (token == null) {
throw new HydraLabRuntimeException(HttpStatus.UNAUTHORIZED.value(), "Invalid visit with no auth code");
}
if (!storageTokenManageService.validateTokenVal(token)) {
throw new HydraLabRuntimeException(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, error access token for storage actions.");
}
final String appendPath = request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE).toString();
final String bestMatchingPattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE).toString();
String fileUri = new AntPathMatcher().extractPathWithinPattern(bestMatchingPattern, appendPath);
if (!LogUtils.isLegalStr(fileUri, Const.RegexString.STORAGE_FILE_REL_PATH, false)) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid file path, file name should not include ';'!");
}

File file = new File(Const.LocalStorageURL.CENTER_LOCAL_STORAGE_ROOT + fileUri);
File file = LocalStorageIOUtil.resolveFilePath(fileUri).toFile();
if (!storageTokenManageService.validateTokenVal(token, fileUri)) {
throw new HydraLabRuntimeException(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, error access token for storage actions.");
}
if (!file.exists()) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), String.format("File %s not exist!", fileUri));
}
Expand All @@ -166,6 +162,15 @@ public void getDownloadFile(HttpServletRequest request,
logger.info(String.format("Output file: %s , size: %d!", fileUri, resLen));
}

static String extractBearerToken(String authorizationHeader) {
String prefix = "Bearer ";
if (authorizationHeader == null || !authorizationHeader.startsWith(prefix)) {
return null;
}
String token = authorizationHeader.substring(prefix.length());
return token.trim().isEmpty() ? null : token;
}

@GetMapping("/api/storage/getFileDownloadToken")
public Result generateReadToken(@CurrentSecurityContext SysUser requestor,
@QueryParam("fileUri") String fileUri) {
Expand All @@ -174,7 +179,9 @@ public Result generateReadToken(@CurrentSecurityContext SysUser requestor,
}

if (fileUri.startsWith("/devices/screenshots/")) {
return Result.ok(storageTokenManageService.generateReadTokenForFile(requestor.getMailAddress(), "images" + fileUri).getToken());
String screenshotStoragePath = getScreenshotStoragePath(fileUri);
return Result.ok(storageTokenManageService.generateReadTokenForFile(
requestor.getMailAddress(), screenshotStoragePath).getToken());
}
String blobPath = fileUri;
if (blobPath.startsWith("/")) {
Expand All @@ -193,4 +200,17 @@ public Result generateReadToken(@CurrentSecurityContext SysUser requestor,

return Result.ok(token);
}

static String getScreenshotStoragePath(String fileUri) {
String screenshotPrefix = "/devices/screenshots/";
if (fileUri == null || !fileUri.startsWith(screenshotPrefix)) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid screenshot path");
}
Path screenshotRoot = Paths.get("images/devices/screenshots").normalize();
Path screenshotPath = Paths.get("images").resolve(fileUri.substring(1)).normalize();
if (screenshotPath.equals(screenshotRoot) || !screenshotPath.startsWith(screenshotRoot)) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid screenshot path");
}
return screenshotPath.toString().replace(File.separatorChar, '/');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@
import com.microsoft.hydralab.common.entity.common.StorageFileInfo;
import com.microsoft.hydralab.common.file.AccessToken;
import com.microsoft.hydralab.common.file.StorageServiceClientProxy;
import com.microsoft.hydralab.common.file.impl.local.LocalStorageProperty;
import com.microsoft.hydralab.common.util.Const;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;

import javax.annotation.Resource;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Supplier;

/**
* @author Li Shen
Expand All @@ -29,19 +36,15 @@ public class StorageTokenManageService {
SysUserService sysUserService;
@Resource
UserTeamManagementService userTeamManagementService;
private final ConcurrentMap<String, AccessToken> accessTokenMap = new ConcurrentHashMap<>();
@Resource
LocalStorageProperty localStorageProperty;
private final ConcurrentMap<String, TokenGrant> accessTokenMap = new ConcurrentHashMap<>();
private final ConcurrentMap<String, TokenGrant> tokenGrantMap = new ConcurrentHashMap<>();

public AccessToken generateReadToken(String uniqueId) {
Assert.notNull(uniqueId, "The key of access token can't be null!");
AccessToken accessToken = accessTokenMap.get(uniqueId);

if (accessToken == null || storageServiceClientProxy.isAccessTokenExpired(accessToken)) {
accessToken = storageServiceClientProxy.generateAccessToken(Const.FilePermission.READ);
Assert.notNull(accessToken, "Generate access token with READ permission failed! Access token generated is null!");
accessTokenMap.put(uniqueId, accessToken);
}

return accessToken;
return getOrGenerateToken(uniqueId, Const.FilePermission.READ, null,
() -> storageServiceClientProxy.generateAccessToken(Const.FilePermission.READ));
}

public boolean checkFileAuthorization(SysUser requestor, StorageFileInfo storageFileInfo) {
Expand Down Expand Up @@ -70,52 +73,116 @@ public boolean checkFileAuthorization(SysUser requestor, StorageFileInfo storage
public AccessToken generateReadTokenForFile(String uniqueId, String fileUri) {
Assert.notNull(uniqueId, "The key of access token can't be null!");
Assert.notNull(fileUri, "The file URI can't be null!");
AccessToken accessToken = accessTokenMap.get(uniqueId + fileUri);

if (accessToken == null || storageServiceClientProxy.isAccessTokenExpired(accessToken)) {
accessToken = storageServiceClientProxy.generateAccessTokenForFile(Const.FilePermission.READ, fileUri);
Assert.notNull(accessToken, "Generate access token with READ permission failed! Access token generated is null!");
accessTokenMap.put(uniqueId + fileUri, accessToken);
}

return accessToken;
String normalizedFileUri = normalizeFileUri(fileUri);
return getOrGenerateToken(uniqueId, Const.FilePermission.READ, normalizedFileUri,
() -> storageServiceClientProxy.generateAccessTokenForFile(Const.FilePermission.READ, normalizedFileUri));
}

public AccessToken generateWriteToken(String uniqueId) {
Assert.notNull(uniqueId, "The key of access token can't be null!");
AccessToken accessToken = accessTokenMap.get(uniqueId);
return getOrGenerateToken(uniqueId, Const.FilePermission.WRITE, null,
() -> storageServiceClientProxy.generateAccessToken(Const.FilePermission.WRITE));
}

public boolean validateAccessToken(String accessToken, String requiredPermission) {
return validateAccessToken(accessToken, requiredPermission, null);
}

if (accessToken == null || storageServiceClientProxy.isAccessTokenExpired(accessToken)) {
accessToken = storageServiceClientProxy.generateAccessToken(Const.FilePermission.WRITE);
Assert.notNull(accessToken, "Generate access token with WRITE permission failed! Access token generated is null!");
accessTokenMap.put(uniqueId, accessToken);
public boolean validateAccessToken(String accessToken, String requiredPermission, String fileUri) {
if (StringUtils.isBlank(accessToken) || StringUtils.isBlank(requiredPermission)) {
return false;
}
if (tokensEqual(accessToken, localStorageProperty.getToken())) {
return true;
}
return validateGrant(accessToken, requiredPermission, fileUri);
}

public boolean validateTokenVal(String token, String fileUri) {
if (StringUtils.isBlank(token)) {
return false;
}
return validateGrant("token=" + token, Const.FilePermission.READ, fileUri);
}

private synchronized AccessToken getOrGenerateToken(String uniqueId, String permission, String fileUri,
Supplier<AccessToken> tokenSupplier) {
String key = uniqueId + "|" + permission + "|" + StringUtils.defaultString(fileUri);
TokenGrant currentGrant = accessTokenMap.get(key);
if (currentGrant != null && !isGrantExpired(currentGrant)) {
return currentGrant.accessToken;
}

AccessToken accessToken = tokenSupplier.get();
Assert.notNull(accessToken, "Generate access token with " + permission
+ " permission failed! Access token generated is null!");
TokenGrant newGrant = new TokenGrant(accessToken, permission, fileUri);
TokenGrant previousGrant = accessTokenMap.put(key, newGrant);
if (previousGrant != null) {
tokenGrantMap.remove(previousGrant.accessToken.getToken(), previousGrant);
}
tokenGrantMap.put(accessToken.getToken(), newGrant);
return accessToken;
}

// todo: to be updated when needed: check token in the format of "token=xxx&expiryTime=yyy&permission=zzz"
public boolean validateAccessToken(String accessToken) {
return !StringUtils.isBlank(accessToken);
private boolean validateGrant(String token, String requiredPermission, String fileUri) {
TokenGrant grant = tokenGrantMap.get(token);
if (grant == null || !requiredPermission.equals(grant.permission)) {
return false;
}
if (isGrantExpired(grant)) {
tokenGrantMap.remove(token, grant);
return false;
}
if (grant.fileUri == null) {
return true;
}
try {
return grant.fileUri.equals(normalizeFileUri(fileUri));
} catch (IllegalArgumentException e) {
return false;
}
}

// todo: specify content
// for subfield "token" of AccessToken of storage type LOCAL. Differentiate validation method here as AccessToken is split by HTTP PATH EXTRACTION from frontend request already
public boolean validateTokenVal(String token) {
return !StringUtils.isBlank(token);
private boolean isGrantExpired(TokenGrant grant) {
return !Const.FilePermission.WRITE.equals(grant.permission)
&& storageServiceClientProxy.isAccessTokenExpired(grant.accessToken);
}

@Deprecated
public AccessToken temporaryGetReadSAS(String uniqueId) {
Assert.notNull(uniqueId, "The key of access token can't be null!");
AccessToken accessToken = accessTokenMap.get(uniqueId);
private static String normalizeFileUri(String fileUri) {
if (StringUtils.isBlank(fileUri)) {
throw new IllegalArgumentException("The file URI can't be blank");
}
try {
return Paths.get(fileUri).normalize().toString().replace(File.separatorChar, '/');
} catch (InvalidPathException e) {
throw new IllegalArgumentException("Invalid file URI", e);
}
}

if (accessToken == null || storageServiceClientProxy.isAccessTokenExpired(accessToken)) {
accessToken = storageServiceClientProxy.generateAccessToken(Const.FilePermission.READ);
Assert.notNull(accessToken, "Current storage service doesn't config READ permission!");
accessTokenMap.put(uniqueId, accessToken);
private static boolean tokensEqual(String first, String second) {
if (first == null || second == null) {
return false;
}
return MessageDigest.isEqual(first.getBytes(StandardCharsets.UTF_8), second.getBytes(StandardCharsets.UTF_8));
}

private static final class TokenGrant {
private final AccessToken accessToken;
private final String permission;
private final String fileUri;

private TokenGrant(AccessToken accessToken, String permission, String fileUri) {
this.accessToken = accessToken;
this.permission = permission;
this.fileUri = fileUri;
}
}

@Deprecated
public AccessToken temporaryGetReadSAS(String uniqueId) {
Assert.notNull(uniqueId, "The key of access token can't be null!");
AccessToken accessToken = generateReadToken(uniqueId);
accessToken.copySignature();
return accessToken;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,7 @@ private LocalStorageIOUtil() {
}

public static void copyUploadedStreamToFile(InputStream inputStream, String fileUri) {
Path publicFolder = Paths.get(Const.LocalStorageURL.CENTER_LOCAL_STORAGE_ROOT).normalize().toAbsolutePath();
Path filePath = publicFolder.resolve(fileUri).normalize().toAbsolutePath();
if (!filePath.startsWith(publicFolder + File.separator)) {
throw new HydraLabRuntimeException("Invalid file uri");
}
File file = new File(Const.LocalStorageURL.CENTER_LOCAL_STORAGE_ROOT + fileUri);
File file = resolveFilePath(fileUri).toFile();
File parentDirFile = new File(file.getParent());
if (!parentDirFile.exists() && !parentDirFile.mkdirs()) {
throw new HydraLabRuntimeException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "mkdirs failed!");
Expand All @@ -48,6 +43,18 @@ public static void copyUploadedStreamToFile(InputStream inputStream, String file
}
}

public static Path resolveFilePath(String fileUri) {
if (fileUri == null) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid file path");
}
Path storageRoot = Paths.get(Const.LocalStorageURL.CENTER_LOCAL_STORAGE_ROOT).toAbsolutePath().normalize();
Path filePath = storageRoot.resolve(fileUri).toAbsolutePath().normalize();
if (filePath.equals(storageRoot) || !filePath.startsWith(storageRoot)) {
throw new HydraLabRuntimeException(HttpStatus.BAD_REQUEST.value(), "Invalid file path");
}
return filePath;
}

public static int copyDownloadedStreamToResponse(File file, OutputStream os) {
int resLen;
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file))) {
Expand Down
2 changes: 1 addition & 1 deletion center/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ app:
SASExpiryUpdate: ${BLOB_SAS_EXPIRY_UPDATE:10}
timeUnit: ${TIME_UNIT:MINUTES}
local:
token: ${CENTER_TOKEN:token=CENTER_LOCAL_STORAGE_TOKEN}
token: ${CENTER_TOKEN:}
endpoint: ${LOCAL_STORAGE_ENDPOINT:http://localhost:9886/}
fileExpiryDay: ${fileExpiryDay:-1}
location: ${user.dir}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package com.microsoft.hydralab.center.controller;

import com.microsoft.hydralab.common.util.HydraLabRuntimeException;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

class StorageControllerTest {
@Test
void acceptsOnlyStrictBearerAuthorizationHeader() {
assertEquals("token=issued", StorageController.extractBearerToken("Bearer token=issued"));
assertNull(StorageController.extractBearerToken("token=issued"));
assertNull(StorageController.extractBearerToken("BearerBearer token=issued"));
assertNull(StorageController.extractBearerToken("Bearer "));
assertNull(StorageController.extractBearerToken("Bearer "));
}

@Test
void screenshotTokenPathCannotEscapeScreenshotStorage() {
assertEquals("images/devices/screenshots/device/screen.png",
StorageController.getScreenshotStoragePath("/devices/screenshots/device/screen.png"));
assertThrows(HydraLabRuntimeException.class,
() -> StorageController.getScreenshotStoragePath(
"/devices/screenshots/../../../pkgstore/private.apk"));
}
}
Loading
Loading