Skip to content

Commit

Permalink
Merge branch 'release/3.1.1'
Browse files Browse the repository at this point in the history
  • Loading branch information
LEDfan committed Jun 20, 2024
2 parents 8ae5076 + f40c6fd commit 14a3224
Show file tree
Hide file tree
Showing 16 changed files with 283 additions and 24 deletions.
10 changes: 10 additions & 0 deletions owasp-suppression.xml
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,14 @@
<!-- Related to nim lang, not Java-->
<cve>CVE-2020-23171</cve>
</suppress>

<suppress>
<!-- Not applicable, Spring does not accept requests without Host header -->
<cve>CVE-2016-6311</cve>
</suppress>

<suppress>
<!-- Disputed by developers, not relevant for ShinyProxy -->
<cve>CVE-2023-35116</cve>
</suppress>
</suppressions>
8 changes: 4 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>eu.openanalytics</groupId>
<artifactId>shinyproxy</artifactId>
<version>3.1.0</version>
<version>3.1.1</version>
<packaging>jar</packaging>

<name>ShinyProxy</name>
Expand All @@ -19,7 +19,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
<version>3.2.6</version>
<relativePath/>
</parent>

Expand All @@ -28,9 +28,9 @@
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<containerproxy.version>1.1.0</containerproxy.version>
<containerproxy.version>1.1.1</containerproxy.version>
<resource.delimiter>&amp;</resource.delimiter>
<spring-boot.version>3.2.2</spring-boot.version>
<spring-boot.version>3.2.6</spring-boot.version>
</properties>

<distributionManagement>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public void sendRedirect(HttpServletRequest request, HttpServletResponse respons
http.authorizeHttpRequests(authz -> authz
.requestMatchers(
new MvcRequestMatcher(handlerMappingIntrospector, "/admin"),
new MvcRequestMatcher(handlerMappingIntrospector, "/admin/data"))
new MvcRequestMatcher(handlerMappingIntrospector, "/admin/**"))
.access((authentication, context) -> new AuthorizationDecision(userService.isAdmin(authentication.get())))
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ private String admin(ModelMap map, HttpServletRequest request) {
@RequestMapping(value = "/admin/data", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.GET)
@ResponseBody
private ResponseEntity<ApiResponse<List<ProxyInfo>>> adminData() {
// TODO rename to /admin/proxy
List<Proxy> proxies = proxyService.getAllProxies();
List<ProxyInfo> proxyInfos = proxies.stream().map(ProxyInfo::new).toList();
return ApiResponse.success(proxyInfos);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@
import eu.openanalytics.containerproxy.util.ProxyMappingManager;
import eu.openanalytics.shinyproxy.ShinyProxyIframeScriptInjector;
import eu.openanalytics.shinyproxy.controllers.dto.ShinyProxyApiResponse;
import eu.openanalytics.shinyproxy.external.ExternalAppSpecExtension;
import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey;
import eu.openanalytics.shinyproxy.runtimevalues.UserTimeZoneKey;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.undertow.util.HttpString;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Expand Down Expand Up @@ -88,6 +90,7 @@
public class AppController extends BaseController {

private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpString acceptEncodingHeader = new HttpString("Accept-Encoding");
@Inject
private ProxyMappingManager mappingManager;
@Inject
Expand Down Expand Up @@ -362,7 +365,7 @@ public void appProxyHtml(@PathVariable String targetId, HttpServletRequest reque
try {
String scriptPath = contextPathHelper.withEndingSlash() + identifierService.instanceId + "/js/shiny.iframe.js";
mappingManager.dispatchAsync(proxy, subPath, request, response, (exchange) -> {
exchange.getRequestHeaders().remove("Accept-Encoding"); // ensure no encoding is used
exchange.getRequestHeaders().put(acceptEncodingHeader, "identity"); // ensure no encoding is used
exchange.addResponseWrapper((factory, exchange1) -> new ShinyProxyIframeScriptInjector(factory.create(), exchange1, scriptPath));
});
} catch (Exception e) {
Expand Down Expand Up @@ -440,6 +443,12 @@ private String getPublicPath(String targetId) {
* @return a RedirectView if a redirect is needed
*/
private Optional<RedirectView> createRedirectIfRequired(HttpServletRequest request, String subPath, ProxySpec spec) {
// if it's an external app -> redirect
String externalUrl = spec.getSpecExtension(ExternalAppSpecExtension.class).getExternalUrl();
if (externalUrl != null) {
return Optional.of(new RedirectView(externalUrl));
}

// if sub-path is empty or it's a slash -> no redirect required
if (subPath.isEmpty() || subPath.equals("/")) {
return Optional.empty();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* ShinyProxy
*
* Copyright (C) 2016-2024 Open Analytics
*
* ===========================================================================
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Apache License as published by
* The Apache Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Apache License for more details.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/>
*/
package eu.openanalytics.shinyproxy.controllers;

import eu.openanalytics.containerproxy.api.dto.ApiResponse;
import eu.openanalytics.containerproxy.event.RemoveDelegateProxiesEvent;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.inject.Inject;

@Controller
public class DelegateProxyAdminController extends BaseController {

@Inject
private ApplicationEventPublisher applicationEventPublisher;

@Operation(summary = "Stops DelegateProxies. Can only be used by admins. If no parameters are specified, all DelegateProxies (of all specs) are stopped. " +
"DelegateProxies that have claimed seats will be stopped as soon as all seats are released. " +
"New DelegateProxies are automatically created to meet the minimum number of seats.",
tags = "ShinyProxy"
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "The DelegateProxies are being stopped.",
content = {
@Content(
mediaType = "application/json",
examples = {
@ExampleObject(value = "{\"status\":\"success\", \"data\": null}")
}
)
}),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Invalid request, no DelegateProxies are being stopped.",
content = {
@Content(
mediaType = "application/json",
examples = {
@ExampleObject(name = "Both id and specId are specified, provide only a single parameter.", value = "{\"status\":\"fail\",\"data\":\"Id and specId cannot be specified at the same time\"}"),
}
)
}),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "Forbidden, you are not an admin user.",
content = {
@Content(
mediaType = "application/json",
examples = {@ExampleObject(value = "{\"status\": \"fail\", \"data\": \"forbidden\"}")}
)
}),
})

@RequestMapping(value = "/admin/delegate-proxy", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.DELETE)
@ResponseBody
public ResponseEntity<ApiResponse<Object>> stopDelegateProxies(
@Parameter(description = "If specified stops the DelegateProxy with this id") @RequestParam(required = false) String id,
@Parameter(description = "If specified stops all DelegateProxies of this specId ") @RequestParam(required = false) String specId
) {

if (id != null && specId != null) {
return ApiResponse.fail("Id and specId cannot be specified at the same time");
}

applicationEventPublisher.publishEvent(new RemoveDelegateProxiesEvent(id, specId));

return ApiResponse.success();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,12 @@ public ResponseEntity<ApiResponse<Proxy>> changeProxyUserId(@PathVariable String
}
instanceName = StringUtils.left(proxy.getUserId() + "-" + instanceName, 64);

proxyStore.removeProxy(proxy); // required to clear all caches
proxy = proxy.toBuilder()
.userId(changeProxyUserIdDto.getUserId())
.addRuntimeValue(new RuntimeValue(AppInstanceKey.inst, instanceName), true)
.build();
proxyStore.updateProxy(proxy);
proxyStore.addProxy(proxy);
} catch (AccessDeniedException ex) {
return ApiResponse.failForbidden();
}
Expand Down
20 changes: 6 additions & 14 deletions src/main/resources/static/js/shiny.app.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Shiny.app = {
},
appPath: null, // guaranteed to start with /
containerSubPath: null,
wasAutomaticReloaded: false
noAutomaticReloaded: null
},

runtimeState: {
Expand Down Expand Up @@ -166,18 +166,6 @@ Shiny.app = {
Shiny.app.startupFailed();
} else {
Shiny.app.loadApp();
if (!Shiny.app.staticState.wasAutomaticReloaded) {
Shiny.app.checkAppCrashedOrStopped(false).then((appCrashedOrStopped) => {
if (appCrashedOrStopped) {
Shiny.ui.showLoading();
const url = new URL(window.location);
url.searchParams.append("sp_automatic_reload", "true");
window.location = url;
}
});
} else {
Shiny.app.checkAppCrashedOrStopped();
}
}
},
submitParameters(parameters) {
Expand Down Expand Up @@ -266,9 +254,13 @@ Shiny.app = {
// be attempted. After reload this flag is removed from the URL.
const url = new URL(window.location);
if (url.searchParams.has("sp_automatic_reload")) {
Shiny.app.staticState.wasAutomaticReloaded = true;
url.searchParams.delete("sp_automatic_reload");
window.history.replaceState(null, null, url);
Shiny.app.staticState.noAutomaticReloaded = true;
} else if (Shiny.app.runtimeState.proxy && Shiny.app.runtimeState.proxy.status === "Up") {
Shiny.app.staticState.noAutomaticReloaded = true;
} else{
Shiny.app.staticState.noAutomaticReloaded = false;
}
}
}
Expand Down
9 changes: 8 additions & 1 deletion src/main/resources/static/js/shiny.ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ Shiny.ui = {
const _shinyFrame = document.getElementById('shinyframe');
const content = _shinyFrame.contentDocument.documentElement.textContent || _shinyFrame.contentDocument.documentElement.innerText;
if (content === '{"status":"fail","data":"app_crashed"}' || content === '{\"status\":\"fail\",\"data\":\"app_stopped_or_non_existent\"}') {
Shiny.ui.showCrashedPage();
if (!Shiny.app.staticState.noAutomaticReloaded) {
Shiny.ui.showLoading();
const url = new URL(window.location);
url.searchParams.append("sp_automatic_reload", "true");
window.location = url;
} else {
Shiny.ui.showCrashedPage();
}
}
if (content === '{"status":"fail","data":"shinyproxy_authentication_required"}') {
shinyProxy.ui.showLoggedOutPage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ public void testProxy() {
resp = apiTestHelper.callWithAuth(apiTestHelper.createRequest("/app_proxy/" + id + "/").addHeader("Accept", "text/html"));
resp.assertHtmlSuccess();
Assertions.assertTrue(resp.body().contains("Welcome to nginx!"));
Assertions.assertTrue(resp.body().endsWith("<script src='/5e89c377af39026486b5a487ad46f0b55d6031aa/js/shiny.iframe.js'></script>"));
Assertions.assertTrue(resp.body().endsWith("<script src='/9f0e4e7085654f8393139ec029b480b1ca8bbe96/js/shiny.iframe.js'></script>"));

// normal sub-path request
resp = apiTestHelper.callWithAuth(apiTestHelper.createRequest("/app_proxy/" + id + "/my-path"));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* ShinyProxy
*
* Copyright (C) 2016-2024 Open Analytics
*
* ===========================================================================
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Apache License as published by
* The Apache Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Apache License for more details.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/>
*/
package eu.openanalytics.shinyproxy.test.api;

import eu.openanalytics.containerproxy.test.helpers.ShinyProxyInstance;
import eu.openanalytics.shinyproxy.test.helpers.ApiTestHelper;
import eu.openanalytics.shinyproxy.test.helpers.Response;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;

public class DelegateProxyAdminControllerTest {

private static final ShinyProxyInstance inst = new ShinyProxyInstance("application-test-delegate.yml");
private static final ApiTestHelper apiTestHelper = new ApiTestHelper(inst);
private static final String RANDOM_UUID = "8402e8c3-eaef-4fc7-9f23-9e843739dd0f";

@AfterAll
public static void afterAll() {
inst.close();
}

@Test
public void testWithoutAuth() {
Response resp = apiTestHelper.callWithoutAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy"));
resp.assertHtmlAuthenticationRequired();

resp = apiTestHelper.callWithoutAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?id=" + RANDOM_UUID));
resp.assertHtmlAuthenticationRequired();

resp = apiTestHelper.callWithoutAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?specId=01_hello"));
resp.assertHtmlAuthenticationRequired();

// get does nothing for now
resp = apiTestHelper.callWithoutAuth(apiTestHelper.createRequest("/admin/delegate-proxy"));
resp.assertHtmlAuthenticationRequired(); // TODO
}

@Test
public void testNonAdminUser() {
Response resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createDeleteRequest("/admin/delegate-proxy"));
resp.assertForbidden();

resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?id=" + RANDOM_UUID));
resp.assertForbidden();

resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?specId=01_hello"));
resp.assertForbidden();

// get does nothing for now
resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createRequest("/admin/delegate-proxy"));
resp.assertForbidden(); // TODO
}

@Test
public void testAdminUser() {
Response resp = apiTestHelper.callWithAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy"));
resp.jsonSuccess();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public void testWithoutAuth() {
public void testListSpecs() {
Response resp = apiTestHelper.callWithAuth(apiTestHelper.createRequest("/api/proxyspec"));
JsonArray specs = resp.jsonSuccess().asJsonArray();
Assertions.assertEquals(1, specs.size());
Assertions.assertEquals(2, specs.size());
JsonObject spec = specs.getJsonObject(0);
// response may not contain any sensitive values
Assertions.assertEquals(List.of("id", "displayName", "description", "logoWidth", "logoHeight", "logoStyle", "logoClasses"), spec.keySet().stream().toList());
Expand Down
Loading

0 comments on commit 14a3224

Please sign in to comment.