Skip to content

Commit f574feb

Browse files
authored
fix: validate application config file reads (#4351)
* Update version to 2.1.8 * fix(console): validate pagination sort fields Validate user-provided pagination sort fields before building MyBatis order clauses.
1 parent e0893f6 commit f574feb

8 files changed

Lines changed: 240 additions & 11 deletions

File tree

streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/controller/ApplicationController.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ public RestResponse yarn() {
177177

178178
@PostMapping("name")
179179
@PermissionScope(app = "#app.id", team = "#app.teamId")
180-
public RestResponse yarnName(Application app) {
180+
public RestResponse yarnName(Application app) throws IOException {
181181
String yarnName = applicationService.getYarnName(app);
182182
return RestResponse.success(yarnName);
183183
}
@@ -190,8 +190,9 @@ public RestResponse checkName(Application app) {
190190
}
191191

192192
@PostMapping("readConf")
193-
public RestResponse readConf(String config) throws IOException {
194-
String content = applicationService.readConf(config);
193+
@PermissionScope(team = "#app.teamId")
194+
public RestResponse readConf(Application app) throws IOException {
195+
String content = applicationService.readConf(app);
195196
return RestResponse.success(content);
196197
}
197198

streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/ApplicationService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public interface ApplicationService extends IService<Application> {
5454

5555
void restart(Application application) throws Exception;
5656

57-
String getYarnName(Application app);
57+
String getYarnName(Application app) throws IOException;
5858

5959
AppExistsState checkExists(Application app);
6060

@@ -66,7 +66,7 @@ public interface ApplicationService extends IService<Application> {
6666

6767
void clean(Application app);
6868

69-
String readConf(String config) throws IOException;
69+
String readConf(Application application) throws IOException;
7070

7171
Application getApp(Application application);
7272

streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ApplicationServiceImpl.java

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -699,10 +699,11 @@ public AppExistsState checkStart(Application appParam) {
699699
}
700700

701701
@Override
702-
public String getYarnName(Application appParam) {
702+
public String getYarnName(Application appParam) throws IOException {
703+
File file = getReadableConfFile(appParam);
703704
String[] args = new String[2];
704705
args[0] = "--name";
705-
args[1] = appParam.getConfig();
706+
args[1] = file.getAbsolutePath();
706707
return ParameterCli.read(args);
707708
}
708709

@@ -1213,12 +1214,62 @@ public void clean(Application appParam) {
12131214
}
12141215

12151216
@Override
1216-
public String readConf(String config) throws IOException {
1217-
File file = new File(config);
1217+
public String readConf(Application application) throws IOException {
1218+
File file = getReadableConfFile(application);
12181219
String conf = FileUtils.readFileToString(file, StandardCharsets.UTF_8);
12191220
return Base64.getEncoder().encodeToString(conf.getBytes());
12201221
}
12211222

1223+
@VisibleForTesting
1224+
File getReadableConfFile(Application application) throws IOException {
1225+
ApiAlertException.throwIfNull(application, "Invalid application.");
1226+
ApiAlertException.throwIfNull(application.getProjectId(), "Invalid project.");
1227+
ApiAlertException.throwIfNull(application.getTeamId(), "Invalid team.");
1228+
ApiAlertException.throwIfTrue(StringUtils.isBlank(application.getModule()), "Invalid module.");
1229+
ApiAlertException.throwIfTrue(
1230+
StringUtils.containsAny(application.getModule(), '/', '\\'), "Invalid module.");
1231+
ApiAlertException.throwIfTrue(StringUtils.isBlank(application.getConfig()), "Invalid config.");
1232+
1233+
Project project = projectService.getById(application.getProjectId());
1234+
ApiAlertException.throwIfNull(project, "Invalid project.");
1235+
ApiAlertException.throwIfFalse(
1236+
application.getTeamId().equals(project.getTeamId()), "Invalid project.");
1237+
1238+
File projectDistHome = project.getDistHome().getCanonicalFile();
1239+
ApiAlertException.throwIfFalse(projectDistHome.isDirectory(), "Invalid project.");
1240+
1241+
File moduleArchive = new File(projectDistHome, application.getModule()).getCanonicalFile();
1242+
File moduleHome =
1243+
new File(StringUtils.removeEnd(moduleArchive.getAbsolutePath(), ".tar.gz"))
1244+
.getCanonicalFile();
1245+
ApiAlertException.throwIfFalse(
1246+
isDirectChildPath(projectDistHome, moduleArchive), "Invalid module.");
1247+
ApiAlertException.throwIfFalse(
1248+
isDirectChildPath(projectDistHome, moduleHome), "Invalid module.");
1249+
ApiAlertException.throwIfFalse(moduleHome.isDirectory(), "Invalid module.");
1250+
1251+
File confHome = new File(moduleHome, "conf").getCanonicalFile();
1252+
ApiAlertException.throwIfFalse(isDescendantPath(moduleHome, confHome), "Invalid config.");
1253+
ApiAlertException.throwIfFalse(confHome.isDirectory(), "Invalid config.");
1254+
1255+
File configFile = new File(application.getConfig()).getCanonicalFile();
1256+
1257+
ApiAlertException.throwIfFalse(configFile.isFile(), "Invalid config.");
1258+
ApiAlertException.throwIfFalse(isDescendantPath(confHome, configFile), "Invalid config.");
1259+
return configFile;
1260+
}
1261+
1262+
private boolean isDescendantPath(File parent, File child) throws IOException {
1263+
String parentPath = parent.getCanonicalPath();
1264+
String childPath = child.getCanonicalPath();
1265+
return childPath.startsWith(parentPath.concat(File.separator));
1266+
}
1267+
1268+
private boolean isDirectChildPath(File parent, File child) throws IOException {
1269+
File childParent = child.getCanonicalFile().getParentFile();
1270+
return childParent != null && parent.getCanonicalFile().equals(childParent.getCanonicalFile());
1271+
}
1272+
12221273
@Override
12231274
public Application getApp(Application appParam) {
12241275
Application application = this.baseMapper.getApp(appParam);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.streampark.console.core.service.impl;
19+
20+
import org.apache.streampark.console.base.exception.ApiAlertException;
21+
import org.apache.streampark.console.core.entity.Application;
22+
import org.apache.streampark.console.core.entity.Project;
23+
import org.apache.streampark.console.core.service.ProjectService;
24+
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.api.extension.ExtendWith;
28+
import org.junit.jupiter.api.io.TempDir;
29+
import org.mockito.Mock;
30+
import org.mockito.junit.jupiter.MockitoExtension;
31+
import org.springframework.test.util.ReflectionTestUtils;
32+
33+
import java.io.File;
34+
import java.nio.file.Files;
35+
import java.nio.file.Path;
36+
37+
import static org.assertj.core.api.Assertions.assertThat;
38+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
39+
import static org.mockito.Mockito.when;
40+
41+
@ExtendWith(MockitoExtension.class)
42+
class ApplicationServiceImplTest {
43+
44+
@Mock private ProjectService projectService;
45+
46+
private final ApplicationServiceImpl applicationService = new ApplicationServiceImpl();
47+
48+
@TempDir private Path tempDir;
49+
50+
@BeforeEach
51+
void setUp() {
52+
ReflectionTestUtils.setField(applicationService, "projectService", projectService);
53+
}
54+
55+
@Test
56+
void getReadableConfFileShouldAllowConfigUnderProjectModuleConf() throws Exception {
57+
Path distHome = Files.createDirectory(tempDir.resolve("dist"));
58+
Path configFile =
59+
Files.createDirectories(distHome.resolve("app").resolve("conf"))
60+
.resolve("application.yaml");
61+
Files.write(configFile, "key: value".getBytes());
62+
63+
when(projectService.getById(1L)).thenReturn(project(1L, 10L, distHome));
64+
65+
Application application = application(1L, 10L, "app.tar.gz", configFile);
66+
67+
assertThat(applicationService.getReadableConfFile(application))
68+
.isEqualTo(configFile.toFile().getCanonicalFile());
69+
}
70+
71+
@Test
72+
void getReadableConfFileShouldRejectConfigOutsideProjectModuleConf() throws Exception {
73+
Path distHome = Files.createDirectory(tempDir.resolve("dist"));
74+
Files.createDirectories(distHome.resolve("app").resolve("conf"));
75+
Path secretFile = Files.write(tempDir.resolve("secret.txt"), "secret".getBytes());
76+
77+
when(projectService.getById(1L)).thenReturn(project(1L, 10L, distHome));
78+
79+
Application application = application(1L, 10L, "app.tar.gz", secretFile);
80+
81+
assertThatThrownBy(() -> applicationService.getReadableConfFile(application))
82+
.isInstanceOf(ApiAlertException.class)
83+
.hasMessage("Invalid config.");
84+
}
85+
86+
@Test
87+
void getReadableConfFileShouldRejectModuleTraversal() throws Exception {
88+
Path distHome = Files.createDirectory(tempDir.resolve("dist"));
89+
Path outsideModule = Files.createDirectories(tempDir.resolve("outside").resolve("conf"));
90+
Path configFile =
91+
Files.write(outsideModule.resolve("application.yaml"), "key: value".getBytes());
92+
93+
Application application = application(1L, 10L, "../outside.tar.gz", configFile);
94+
95+
assertThatThrownBy(() -> applicationService.getReadableConfFile(application))
96+
.isInstanceOf(ApiAlertException.class)
97+
.hasMessage("Invalid module.");
98+
}
99+
100+
@Test
101+
void getReadableConfFileShouldRejectModulePathAlias() throws Exception {
102+
Path distHome = Files.createDirectory(tempDir.resolve("dist"));
103+
Path configFile =
104+
Files.createDirectories(distHome.resolve("app").resolve("conf"))
105+
.resolve("application.yaml");
106+
Files.write(configFile, "key: value".getBytes());
107+
108+
Application application = application(1L, 10L, "app/conf/..", configFile);
109+
110+
assertThatThrownBy(() -> applicationService.getReadableConfFile(application))
111+
.isInstanceOf(ApiAlertException.class)
112+
.hasMessage("Invalid module.");
113+
}
114+
115+
@Test
116+
void getReadableConfFileShouldRejectProjectFromOtherTeam() throws Exception {
117+
Path distHome = Files.createDirectory(tempDir.resolve("dist"));
118+
Path configFile =
119+
Files.createDirectories(distHome.resolve("app").resolve("conf"))
120+
.resolve("application.yaml");
121+
Files.write(configFile, "key: value".getBytes());
122+
123+
when(projectService.getById(1L)).thenReturn(project(1L, 20L, distHome));
124+
125+
Application application = application(1L, 10L, "app.tar.gz", configFile);
126+
127+
assertThatThrownBy(() -> applicationService.getReadableConfFile(application))
128+
.isInstanceOf(ApiAlertException.class)
129+
.hasMessage("Invalid project.");
130+
}
131+
132+
@Test
133+
void getYarnNameShouldReadOnlyValidatedConfig() throws Exception {
134+
Path distHome = Files.createDirectory(tempDir.resolve("dist"));
135+
Path configFile =
136+
Files.createDirectories(distHome.resolve("app").resolve("conf"))
137+
.resolve("application.properties");
138+
Files.write(configFile, "flink.property.pipeline.name=test-app".getBytes());
139+
140+
when(projectService.getById(1L)).thenReturn(project(1L, 10L, distHome));
141+
142+
Application application = application(1L, 10L, "app.tar.gz", configFile);
143+
144+
assertThat(applicationService.getYarnName(application)).isEqualTo("test-app");
145+
}
146+
147+
private Application application(Long projectId, Long teamId, String module, Path configFile) {
148+
Application application = new Application();
149+
application.setProjectId(projectId);
150+
application.setTeamId(teamId);
151+
application.setModule(module);
152+
application.setConfig(configFile.toString());
153+
return application;
154+
}
155+
156+
private Project project(Long id, Long teamId, Path distHome) {
157+
return new Project() {
158+
{
159+
setId(id);
160+
setTeamId(teamId);
161+
}
162+
163+
@Override
164+
public File getDistHome() {
165+
return distHome.toFile();
166+
}
167+
};
168+
}
169+
}

streampark-console/streampark-console-webapp/src/api/flink/app/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ enum APP_API {
5353
* read configuration file
5454
* @returns Promise<any>
5555
*/
56-
export function fetchAppConf(params?: { config: any }) {
56+
export function fetchAppConf(params?: { projectId?: any; module?: any; config: any }) {
5757
return defHttp.post<any>({
5858
url: APP_API.READ_CONF,
5959
params,
@@ -226,6 +226,6 @@ export function fetchCancel(data: CancelParam): Promise<boolean> {
226226
return defHttp.post({ url: APP_API.CANCEL, data });
227227
}
228228

229-
export function fetchName(data: { config: string }) {
229+
export function fetchName(data: { projectId?: any; module?: any; config: string }) {
230230
return defHttp.post({ url: APP_API.NAME, data });
231231
}

streampark-console/streampark-console-webapp/src/views/flink/app/Add.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@
188188
params['format'] = getAppConfType(configVal);
189189
if (values.configOverride == null) {
190190
params['config'] = await fetchAppConf({
191+
projectId: params.projectId,
192+
module: params.module,
191193
config: configVal,
192194
});
193195
} else {

streampark-console/streampark-console-webapp/src/views/flink/app/components/AppConf.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,13 @@ export default defineComponent({
6262

6363
async function handleChangeNewConfig(confFile: string) {
6464
const appName = await fetchName({
65+
projectId: unref(model).project,
66+
module: unref(model).module,
6567
config: confFile,
6668
});
6769
const appConf = await fetchAppConf({
70+
projectId: unref(model).project,
71+
module: unref(model).module,
6872
config: confFile,
6973
});
7074
model.value.config = confFile;

streampark-console/streampark-console-webapp/src/views/flink/app/hooks/useCreateSchema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ export const useCreateSchema = (dependencyRef: Ref) => {
263263
fieldNames: { children: 'children', label: 'title', key: 'value', value: 'value' },
264264
onChange: (value: string) => {
265265
fetchName({
266+
projectId: formModel.project,
267+
module: formModel.module,
266268
config: value,
267269
}).then((resp) => {
268270
formModel.jobName = resp;

0 commit comments

Comments
 (0)