Skip to content

Commit 14a3224

Browse files
committed
Merge branch 'release/3.1.1'
2 parents 8ae5076 + f40c6fd commit 14a3224

16 files changed

+283
-24
lines changed

owasp-suppression.xml

+10
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,14 @@
128128
<!-- Related to nim lang, not Java-->
129129
<cve>CVE-2020-23171</cve>
130130
</suppress>
131+
132+
<suppress>
133+
<!-- Not applicable, Spring does not accept requests without Host header -->
134+
<cve>CVE-2016-6311</cve>
135+
</suppress>
136+
137+
<suppress>
138+
<!-- Disputed by developers, not relevant for ShinyProxy -->
139+
<cve>CVE-2023-35116</cve>
140+
</suppress>
131141
</suppressions>

pom.xml

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>eu.openanalytics</groupId>
77
<artifactId>shinyproxy</artifactId>
8-
<version>3.1.0</version>
8+
<version>3.1.1</version>
99
<packaging>jar</packaging>
1010

1111
<name>ShinyProxy</name>
@@ -19,7 +19,7 @@
1919
<parent>
2020
<groupId>org.springframework.boot</groupId>
2121
<artifactId>spring-boot-starter-parent</artifactId>
22-
<version>3.2.2</version>
22+
<version>3.2.6</version>
2323
<relativePath/>
2424
</parent>
2525

@@ -28,9 +28,9 @@
2828
<java.version>17</java.version>
2929
<maven.compiler.source>17</maven.compiler.source>
3030
<maven.compiler.target>17</maven.compiler.target>
31-
<containerproxy.version>1.1.0</containerproxy.version>
31+
<containerproxy.version>1.1.1</containerproxy.version>
3232
<resource.delimiter>&amp;</resource.delimiter>
33-
<spring-boot.version>3.2.2</spring-boot.version>
33+
<spring-boot.version>3.2.6</spring-boot.version>
3434
</properties>
3535

3636
<distributionManagement>

src/main/java/eu/openanalytics/shinyproxy/UISecurityConfig.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ public void sendRedirect(HttpServletRequest request, HttpServletResponse respons
9797
http.authorizeHttpRequests(authz -> authz
9898
.requestMatchers(
9999
new MvcRequestMatcher(handlerMappingIntrospector, "/admin"),
100-
new MvcRequestMatcher(handlerMappingIntrospector, "/admin/data"))
100+
new MvcRequestMatcher(handlerMappingIntrospector, "/admin/**"))
101101
.access((authentication, context) -> new AuthorizationDecision(userService.isAdmin(authentication.get())))
102102
);
103103

src/main/java/eu/openanalytics/shinyproxy/controllers/AdminController.java

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ private String admin(ModelMap map, HttpServletRequest request) {
8787
@RequestMapping(value = "/admin/data", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.GET)
8888
@ResponseBody
8989
private ResponseEntity<ApiResponse<List<ProxyInfo>>> adminData() {
90+
// TODO rename to /admin/proxy
9091
List<Proxy> proxies = proxyService.getAllProxies();
9192
List<ProxyInfo> proxyInfos = proxies.stream().map(ProxyInfo::new).toList();
9293
return ApiResponse.success(proxyInfos);

src/main/java/eu/openanalytics/shinyproxy/controllers/AppController.java

+10-1
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@
4242
import eu.openanalytics.containerproxy.util.ProxyMappingManager;
4343
import eu.openanalytics.shinyproxy.ShinyProxyIframeScriptInjector;
4444
import eu.openanalytics.shinyproxy.controllers.dto.ShinyProxyApiResponse;
45+
import eu.openanalytics.shinyproxy.external.ExternalAppSpecExtension;
4546
import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey;
4647
import eu.openanalytics.shinyproxy.runtimevalues.UserTimeZoneKey;
4748
import io.swagger.v3.oas.annotations.Operation;
4849
import io.swagger.v3.oas.annotations.media.Content;
4950
import io.swagger.v3.oas.annotations.media.ExampleObject;
5051
import io.swagger.v3.oas.annotations.media.Schema;
5152
import io.swagger.v3.oas.annotations.responses.ApiResponses;
53+
import io.undertow.util.HttpString;
5254
import jakarta.servlet.RequestDispatcher;
5355
import jakarta.servlet.http.HttpServletRequest;
5456
import jakarta.servlet.http.HttpServletResponse;
@@ -88,6 +90,7 @@
8890
public class AppController extends BaseController {
8991

9092
private final ObjectMapper objectMapper = new ObjectMapper();
93+
private final HttpString acceptEncodingHeader = new HttpString("Accept-Encoding");
9194
@Inject
9295
private ProxyMappingManager mappingManager;
9396
@Inject
@@ -362,7 +365,7 @@ public void appProxyHtml(@PathVariable String targetId, HttpServletRequest reque
362365
try {
363366
String scriptPath = contextPathHelper.withEndingSlash() + identifierService.instanceId + "/js/shiny.iframe.js";
364367
mappingManager.dispatchAsync(proxy, subPath, request, response, (exchange) -> {
365-
exchange.getRequestHeaders().remove("Accept-Encoding"); // ensure no encoding is used
368+
exchange.getRequestHeaders().put(acceptEncodingHeader, "identity"); // ensure no encoding is used
366369
exchange.addResponseWrapper((factory, exchange1) -> new ShinyProxyIframeScriptInjector(factory.create(), exchange1, scriptPath));
367370
});
368371
} catch (Exception e) {
@@ -440,6 +443,12 @@ private String getPublicPath(String targetId) {
440443
* @return a RedirectView if a redirect is needed
441444
*/
442445
private Optional<RedirectView> createRedirectIfRequired(HttpServletRequest request, String subPath, ProxySpec spec) {
446+
// if it's an external app -> redirect
447+
String externalUrl = spec.getSpecExtension(ExternalAppSpecExtension.class).getExternalUrl();
448+
if (externalUrl != null) {
449+
return Optional.of(new RedirectView(externalUrl));
450+
}
451+
443452
// if sub-path is empty or it's a slash -> no redirect required
444453
if (subPath.isEmpty() || subPath.equals("/")) {
445454
return Optional.empty();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* ShinyProxy
3+
*
4+
* Copyright (C) 2016-2024 Open Analytics
5+
*
6+
* ===========================================================================
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the Apache License as published by
10+
* The Apache Software Foundation, either version 2 of the License, or
11+
* (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* Apache License for more details.
17+
*
18+
* You should have received a copy of the Apache License
19+
* along with this program. If not, see <http://www.apache.org/licenses/>
20+
*/
21+
package eu.openanalytics.shinyproxy.controllers;
22+
23+
import eu.openanalytics.containerproxy.api.dto.ApiResponse;
24+
import eu.openanalytics.containerproxy.event.RemoveDelegateProxiesEvent;
25+
import io.swagger.v3.oas.annotations.Operation;
26+
import io.swagger.v3.oas.annotations.Parameter;
27+
import io.swagger.v3.oas.annotations.media.Content;
28+
import io.swagger.v3.oas.annotations.media.ExampleObject;
29+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
30+
import org.springframework.context.ApplicationEventPublisher;
31+
import org.springframework.http.MediaType;
32+
import org.springframework.http.ResponseEntity;
33+
import org.springframework.stereotype.Controller;
34+
import org.springframework.web.bind.annotation.RequestMapping;
35+
import org.springframework.web.bind.annotation.RequestMethod;
36+
import org.springframework.web.bind.annotation.RequestParam;
37+
import org.springframework.web.bind.annotation.ResponseBody;
38+
39+
import javax.inject.Inject;
40+
41+
@Controller
42+
public class DelegateProxyAdminController extends BaseController {
43+
44+
@Inject
45+
private ApplicationEventPublisher applicationEventPublisher;
46+
47+
@Operation(summary = "Stops DelegateProxies. Can only be used by admins. If no parameters are specified, all DelegateProxies (of all specs) are stopped. " +
48+
"DelegateProxies that have claimed seats will be stopped as soon as all seats are released. " +
49+
"New DelegateProxies are automatically created to meet the minimum number of seats.",
50+
tags = "ShinyProxy"
51+
)
52+
@ApiResponses(value = {
53+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
54+
responseCode = "200",
55+
description = "The DelegateProxies are being stopped.",
56+
content = {
57+
@Content(
58+
mediaType = "application/json",
59+
examples = {
60+
@ExampleObject(value = "{\"status\":\"success\", \"data\": null}")
61+
}
62+
)
63+
}),
64+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
65+
responseCode = "400",
66+
description = "Invalid request, no DelegateProxies are being stopped.",
67+
content = {
68+
@Content(
69+
mediaType = "application/json",
70+
examples = {
71+
@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\"}"),
72+
}
73+
)
74+
}),
75+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
76+
responseCode = "403",
77+
description = "Forbidden, you are not an admin user.",
78+
content = {
79+
@Content(
80+
mediaType = "application/json",
81+
examples = {@ExampleObject(value = "{\"status\": \"fail\", \"data\": \"forbidden\"}")}
82+
)
83+
}),
84+
})
85+
86+
@RequestMapping(value = "/admin/delegate-proxy", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.DELETE)
87+
@ResponseBody
88+
public ResponseEntity<ApiResponse<Object>> stopDelegateProxies(
89+
@Parameter(description = "If specified stops the DelegateProxy with this id") @RequestParam(required = false) String id,
90+
@Parameter(description = "If specified stops all DelegateProxies of this specId ") @RequestParam(required = false) String specId
91+
) {
92+
93+
if (id != null && specId != null) {
94+
return ApiResponse.fail("Id and specId cannot be specified at the same time");
95+
}
96+
97+
applicationEventPublisher.publishEvent(new RemoveDelegateProxiesEvent(id, specId));
98+
99+
return ApiResponse.success();
100+
}
101+
102+
}

src/main/java/eu/openanalytics/shinyproxy/controllers/ProxyApiController.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,12 @@ public ResponseEntity<ApiResponse<Proxy>> changeProxyUserId(@PathVariable String
145145
}
146146
instanceName = StringUtils.left(proxy.getUserId() + "-" + instanceName, 64);
147147

148+
proxyStore.removeProxy(proxy); // required to clear all caches
148149
proxy = proxy.toBuilder()
149150
.userId(changeProxyUserIdDto.getUserId())
150151
.addRuntimeValue(new RuntimeValue(AppInstanceKey.inst, instanceName), true)
151152
.build();
152-
proxyStore.updateProxy(proxy);
153+
proxyStore.addProxy(proxy);
153154
} catch (AccessDeniedException ex) {
154155
return ApiResponse.failForbidden();
155156
}

src/main/resources/static/js/shiny.app.js

+6-14
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Shiny.app = {
5555
},
5656
appPath: null, // guaranteed to start with /
5757
containerSubPath: null,
58-
wasAutomaticReloaded: false
58+
noAutomaticReloaded: null
5959
},
6060

6161
runtimeState: {
@@ -166,18 +166,6 @@ Shiny.app = {
166166
Shiny.app.startupFailed();
167167
} else {
168168
Shiny.app.loadApp();
169-
if (!Shiny.app.staticState.wasAutomaticReloaded) {
170-
Shiny.app.checkAppCrashedOrStopped(false).then((appCrashedOrStopped) => {
171-
if (appCrashedOrStopped) {
172-
Shiny.ui.showLoading();
173-
const url = new URL(window.location);
174-
url.searchParams.append("sp_automatic_reload", "true");
175-
window.location = url;
176-
}
177-
});
178-
} else {
179-
Shiny.app.checkAppCrashedOrStopped();
180-
}
181169
}
182170
},
183171
submitParameters(parameters) {
@@ -266,9 +254,13 @@ Shiny.app = {
266254
// be attempted. After reload this flag is removed from the URL.
267255
const url = new URL(window.location);
268256
if (url.searchParams.has("sp_automatic_reload")) {
269-
Shiny.app.staticState.wasAutomaticReloaded = true;
270257
url.searchParams.delete("sp_automatic_reload");
271258
window.history.replaceState(null, null, url);
259+
Shiny.app.staticState.noAutomaticReloaded = true;
260+
} else if (Shiny.app.runtimeState.proxy && Shiny.app.runtimeState.proxy.status === "Up") {
261+
Shiny.app.staticState.noAutomaticReloaded = true;
262+
} else{
263+
Shiny.app.staticState.noAutomaticReloaded = false;
272264
}
273265
}
274266
}

src/main/resources/static/js/shiny.ui.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ Shiny.ui = {
3030
const _shinyFrame = document.getElementById('shinyframe');
3131
const content = _shinyFrame.contentDocument.documentElement.textContent || _shinyFrame.contentDocument.documentElement.innerText;
3232
if (content === '{"status":"fail","data":"app_crashed"}' || content === '{\"status\":\"fail\",\"data\":\"app_stopped_or_non_existent\"}') {
33-
Shiny.ui.showCrashedPage();
33+
if (!Shiny.app.staticState.noAutomaticReloaded) {
34+
Shiny.ui.showLoading();
35+
const url = new URL(window.location);
36+
url.searchParams.append("sp_automatic_reload", "true");
37+
window.location = url;
38+
} else {
39+
Shiny.ui.showCrashedPage();
40+
}
3441
}
3542
if (content === '{"status":"fail","data":"shinyproxy_authentication_required"}') {
3643
shinyProxy.ui.showLoggedOutPage();

src/test/java/eu/openanalytics/shinyproxy/test/api/AppControllerTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ public void testProxy() {
193193
resp = apiTestHelper.callWithAuth(apiTestHelper.createRequest("/app_proxy/" + id + "/").addHeader("Accept", "text/html"));
194194
resp.assertHtmlSuccess();
195195
Assertions.assertTrue(resp.body().contains("Welcome to nginx!"));
196-
Assertions.assertTrue(resp.body().endsWith("<script src='/5e89c377af39026486b5a487ad46f0b55d6031aa/js/shiny.iframe.js'></script>"));
196+
Assertions.assertTrue(resp.body().endsWith("<script src='/9f0e4e7085654f8393139ec029b480b1ca8bbe96/js/shiny.iframe.js'></script>"));
197197

198198
// normal sub-path request
199199
resp = apiTestHelper.callWithAuth(apiTestHelper.createRequest("/app_proxy/" + id + "/my-path"));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* ShinyProxy
3+
*
4+
* Copyright (C) 2016-2024 Open Analytics
5+
*
6+
* ===========================================================================
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the Apache License as published by
10+
* The Apache Software Foundation, either version 2 of the License, or
11+
* (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* Apache License for more details.
17+
*
18+
* You should have received a copy of the Apache License
19+
* along with this program. If not, see <http://www.apache.org/licenses/>
20+
*/
21+
package eu.openanalytics.shinyproxy.test.api;
22+
23+
import eu.openanalytics.containerproxy.test.helpers.ShinyProxyInstance;
24+
import eu.openanalytics.shinyproxy.test.helpers.ApiTestHelper;
25+
import eu.openanalytics.shinyproxy.test.helpers.Response;
26+
import org.junit.jupiter.api.AfterAll;
27+
import org.junit.jupiter.api.Test;
28+
29+
public class DelegateProxyAdminControllerTest {
30+
31+
private static final ShinyProxyInstance inst = new ShinyProxyInstance("application-test-delegate.yml");
32+
private static final ApiTestHelper apiTestHelper = new ApiTestHelper(inst);
33+
private static final String RANDOM_UUID = "8402e8c3-eaef-4fc7-9f23-9e843739dd0f";
34+
35+
@AfterAll
36+
public static void afterAll() {
37+
inst.close();
38+
}
39+
40+
@Test
41+
public void testWithoutAuth() {
42+
Response resp = apiTestHelper.callWithoutAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy"));
43+
resp.assertHtmlAuthenticationRequired();
44+
45+
resp = apiTestHelper.callWithoutAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?id=" + RANDOM_UUID));
46+
resp.assertHtmlAuthenticationRequired();
47+
48+
resp = apiTestHelper.callWithoutAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?specId=01_hello"));
49+
resp.assertHtmlAuthenticationRequired();
50+
51+
// get does nothing for now
52+
resp = apiTestHelper.callWithoutAuth(apiTestHelper.createRequest("/admin/delegate-proxy"));
53+
resp.assertHtmlAuthenticationRequired(); // TODO
54+
}
55+
56+
@Test
57+
public void testNonAdminUser() {
58+
Response resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createDeleteRequest("/admin/delegate-proxy"));
59+
resp.assertForbidden();
60+
61+
resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?id=" + RANDOM_UUID));
62+
resp.assertForbidden();
63+
64+
resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createDeleteRequest("/admin/delegate-proxy?specId=01_hello"));
65+
resp.assertForbidden();
66+
67+
// get does nothing for now
68+
resp = apiTestHelper.callWithAuthDemo2(apiTestHelper.createRequest("/admin/delegate-proxy"));
69+
resp.assertForbidden(); // TODO
70+
}
71+
72+
@Test
73+
public void testAdminUser() {
74+
Response resp = apiTestHelper.callWithAuth(apiTestHelper.createDeleteRequest("/admin/delegate-proxy"));
75+
resp.jsonSuccess();
76+
}
77+
78+
}

src/test/java/eu/openanalytics/shinyproxy/test/api/ProxyControllerTest.java

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

0 commit comments

Comments
 (0)