1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 package org.codelibs.fess.app.web.admin.design;
17
18 import java.io.File;
19 import java.io.FileInputStream;
20 import java.io.IOException;
21 import java.io.UnsupportedEncodingException;
22 import java.net.URLDecoder;
23 import java.util.ArrayList;
24 import java.util.List;
25 import java.util.Locale;
26
27 import org.apache.commons.io.FileUtils;
28 import org.apache.logging.log4j.LogManager;
29 import org.apache.logging.log4j.Logger;
30 import org.codelibs.core.io.FileUtil;
31 import org.codelibs.core.io.ResourceUtil;
32 import org.codelibs.core.lang.StringUtil;
33 import org.codelibs.core.misc.Pair;
34 import org.codelibs.fess.Constants;
35 import org.codelibs.fess.annotation.Secured;
36 import org.codelibs.fess.app.web.base.FessAdminAction;
37 import org.codelibs.fess.exception.FessSystemException;
38 import org.codelibs.fess.util.ComponentUtil;
39 import org.dbflute.optional.OptionalEntity;
40 import org.lastaflute.web.Execute;
41 import org.lastaflute.web.response.HtmlResponse;
42 import org.lastaflute.web.response.StreamResponse;
43 import org.lastaflute.web.ruts.process.ActionRuntime;
44
45
46
47
48
49 public class AdminDesignAction extends FessAdminAction {
50
51
52
53
54 public AdminDesignAction() {
55 super();
56 }
57
58 private static final String CACHE_AND_SESSION_INVALIDATE_STATEMENT = "<!--CACHE_AND_SESSION_INVALIDATE-->";
59
60 private static final String TRY_STATEMENT = "<!--TRY-->";
61
62
63 public static final String ROLE = "admin-design";
64
65 private static final Logger logger = LogManager.getLogger(AdminDesignAction.class);
66
67
68
69
70
71
72
73
74
75 @Override
76 protected void setupHtmlData(final ActionRuntime runtime) {
77 super.setupHtmlData(runtime);
78 runtime.registerData("fileNameItems", loadFileNameItems());
79 runtime.registerData("jspFileNameItems", loadJspFileNameItems());
80 runtime.registerData("helpLink", systemHelper.getHelpLink(fessConfig.getOnlineHelpNameDesign()));
81 }
82
83 @Override
84 protected String getActionRole() {
85 return ROLE;
86 }
87
88 private List<Pair<String, String>> loadJspFileNameItems() {
89 final List<Pair<String, String>> jspItems = new ArrayList<>();
90 for (final Pair<String, String> p : systemHelper.getDesignJspFileNames()) {
91 jspItems.add(new Pair<>(":" + p.getFirst(), "/" + p.getSecond()));
92 }
93 for (String key : ComponentUtil.getVirtualHostHelper().getVirtualHostPaths()) {
94 if (StringUtil.isBlank(key)) {
95 key = "/";
96 }
97 for (final Pair<String, String> p : systemHelper.getDesignJspFileNames()) {
98 jspItems.add(new Pair<>(key + ":" + p.getFirst(), key + "/" + p.getSecond()));
99 }
100 }
101 return jspItems;
102 }
103
104 private List<String> loadFileNameItems() {
105 final File baseDir = new File(getServletContext().getRealPath("/"));
106 final List<String> fileNameItems = new ArrayList<>();
107 final List<File> fileList = getAccessibleFileList(baseDir);
108 final int length = baseDir.getAbsolutePath().length();
109 for (final File file : fileList) {
110 fileNameItems.add(file.getAbsolutePath().substring(length));
111 }
112 return fileNameItems;
113 }
114
115
116
117
118
119
120
121
122 @Execute
123 @Secured({ ROLE, ROLE + VIEW })
124 public HtmlResponse index() {
125 saveToken();
126 return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class);
127 }
128
129
130
131
132
133 @Execute
134 @Secured({ ROLE, ROLE + VIEW })
135 public HtmlResponse back() {
136 saveToken();
137 return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class);
138 }
139
140
141
142
143
144
145 @Execute
146 @Secured({ ROLE })
147 public HtmlResponse upload(final UploadForm form) {
148 validate(form, messages -> {}, () -> asListHtml(form));
149 verifyToken(this::asListHtml);
150 final String uploadedFileName = form.designFile.getFileName();
151 String fileName = form.designFileName;
152 if (StringUtil.isBlank(fileName)) {
153 fileName = uploadedFileName;
154 try {
155 int pos = fileName.indexOf('/');
156 if (pos >= 0) {
157 fileName = fileName.substring(pos + 1);
158 }
159 pos = fileName.indexOf('\\');
160 if (pos >= 0) {
161 fileName = fileName.substring(pos + 1);
162 }
163 } catch (final Exception e) {
164 logger.warn("Failed to process a request.", e);
165 throwValidationError(messages -> messages.addErrorsDesignFileNameIsInvalid("designFile"), this::asListHtml);
166 }
167 }
168 if (StringUtil.isBlank(fileName)) {
169 throwValidationError(messages -> messages.addErrorsDesignFileNameIsNotFound("designFile"), this::asListHtml);
170 }
171
172 final File baseDir = new File(getServletContext().getRealPath("/"));
173 File uploadFile;
174 File expectedBaseDir;
175
176 if (checkFileType(fileName, fessConfig.getSupportedUploadedMediaExtentionsAsArray())
177 && checkFileType(uploadedFileName, fessConfig.getSupportedUploadedMediaExtentionsAsArray())) {
178 expectedBaseDir = new File(baseDir, "images");
179 uploadFile = new File(getServletContext().getRealPath("/images/" + fileName));
180 } else if (checkFileType(fileName, fessConfig.getSupportedUploadedCssExtentionsAsArray())
181 && checkFileType(uploadedFileName, fessConfig.getSupportedUploadedCssExtentionsAsArray())) {
182 expectedBaseDir = new File(baseDir, "css");
183 uploadFile = new File(getServletContext().getRealPath("/css/" + fileName));
184 } else if (checkFileType(fileName, fessConfig.getSupportedUploadedJsExtentionsAsArray())
185 && checkFileType(uploadedFileName, fessConfig.getSupportedUploadedJsExtentionsAsArray())) {
186 expectedBaseDir = new File(baseDir, "js");
187 uploadFile = new File(getServletContext().getRealPath("/js/" + fileName));
188 } else if (fessConfig.isSupportedUploadedFile(fileName) || fessConfig.isSupportedUploadedFile(uploadedFileName)) {
189 uploadFile = ResourceUtil.getResourceAsFileNoException(fileName);
190 if (uploadFile == null) {
191 throwValidationError(messages -> messages.addErrorsDesignFileNameIsNotFound("designFileName"), this::asListHtml);
192 return null;
193 }
194 expectedBaseDir = null;
195 } else {
196 throwValidationError(messages -> messages.addErrorsDesignFileIsUnsupportedType("designFileName"), this::asListHtml);
197 return null;
198 }
199
200
201 if (expectedBaseDir != null && !isValidUploadPath(uploadFile, expectedBaseDir)) {
202 logger.warn("Path traversal attempt detected: fileName={}", fileName);
203 throwValidationError(messages -> messages.addErrorsDesignFileNameIsInvalid("designFileName"), this::asListHtml);
204 return null;
205 }
206
207 final File parentFile = uploadFile.getParentFile();
208 if (!parentFile.exists() && !parentFile.mkdirs()) {
209 logger.warn("Could not create directory: {}", parentFile.getAbsolutePath());
210 }
211
212 try {
213 write(uploadFile.getAbsolutePath(), form.designFile.getFileData());
214 final String currentFileName = fileName;
215 saveInfo(messages -> messages.addSuccessUploadDesignFile(GLOBAL, currentFileName));
216 } catch (final Exception e) {
217 logger.error("Failed to write an image file: {}", fileName, e);
218 throwValidationError(messages -> messages.addErrorsFailedToWriteDesignImageFile(GLOBAL), this::asListHtml);
219 }
220 return redirect(getClass());
221 }
222
223 private boolean checkFileType(final String fileName, final String[] exts) {
224 if (fileName == null) {
225 return false;
226 }
227 final String lFileName = fileName.toLowerCase(Locale.ENGLISH);
228 for (final String ext : exts) {
229 if (lFileName.endsWith("." + ext)) {
230 return true;
231 }
232 }
233 return false;
234 }
235
236
237
238
239
240
241 @Execute
242 @Secured({ ROLE, ROLE + VIEW })
243 public StreamResponse download(final FileAccessForm form) {
244 final File file = getTargetFile(form.fileName).get();
245 if (file == null) {
246 throwValidationError(messages -> messages.addErrorsTargetFileDoesNotExist(GLOBAL, form.fileName), this::asListHtml);
247 return null;
248 }
249 validate(form, messages -> {}, this::asListHtml);
250 verifyTokenKeep(this::asListHtml);
251 return asStream(file.getName()).contentTypeOctetStream().stream(out -> {
252 try (FileInputStream fis = new FileInputStream(file)) {
253 out.write(fis);
254 }
255 });
256 }
257
258
259
260
261
262
263 @Execute
264 @Secured({ ROLE })
265 public HtmlResponse delete(final FileAccessForm form) {
266 getTargetFile(form.fileName).ifPresent(file -> {
267 if (!file.delete()) {
268 logger.error("Failed to delete design file: {}", file.getAbsolutePath());
269 throwValidationError(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, form.fileName), this::asListHtml);
270 }
271 }).orElse(() -> {
272 throwValidationError(messages -> messages.addErrorsTargetFileDoesNotExist(GLOBAL, form.fileName), this::asListHtml);
273 });
274 saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, form.fileName));
275 validate(form, messages -> {}, this::asListHtml);
276 verifyToken(this::asListHtml);
277 return redirect(getClass());
278 }
279
280
281
282
283
284
285
286
287
288 @Execute
289 @Secured({ ROLE })
290 public HtmlResponse edit(final EditForm form) {
291 final String jspType = "view";
292 final File jspFile = getJspFile(form.fileName, jspType);
293 try {
294 form.content = encodeJsp(new String(FileUtil.readBytes(jspFile), Constants.UTF_8));
295 } catch (final UnsupportedEncodingException e) {
296 throw new FessSystemException("Invalid encoding: fileName=" + form.fileName, e);
297 }
298 saveToken();
299 return asEditHtml(form);
300 }
301
302
303
304
305
306
307 @Execute
308 @Secured({ ROLE })
309 public HtmlResponse editAsUseDefault(final EditForm form) {
310 final String jspType = "orig/view";
311 final File jspFile = getJspFile(form.fileName, jspType);
312 try {
313 form.content = encodeJsp(new String(FileUtil.readBytes(jspFile), Constants.UTF_8));
314 } catch (final UnsupportedEncodingException e) {
315 throw new FessSystemException("Invalid encoding: fileName=" + form.fileName, e);
316 }
317 saveToken();
318 return asEditHtml(form);
319 }
320
321
322
323
324
325
326 @Execute
327 @Secured({ ROLE })
328 public HtmlResponse update(final EditForm form) {
329 final String jspType = "view";
330 final File jspFile = getJspFile(form.fileName, jspType);
331
332 if (form.content == null) {
333 form.content = StringUtil.EMPTY;
334 }
335
336 validate(form, messages -> {}, () -> asEditHtml(form));
337 verifyToken(() -> asEditHtml(form));
338 try {
339 write(jspFile.getAbsolutePath(), decodeJsp(form.content).getBytes(Constants.UTF_8));
340 saveInfo(messages -> messages.addSuccessUpdateDesignJspFile(GLOBAL, jspFile.getAbsolutePath()));
341 } catch (final Exception e) {
342 logger.warn("Failed to update {}", form.fileName, e);
343 throwValidationError(messages -> messages.addErrorsFailedToUpdateJspFile(GLOBAL), this::asListHtml);
344 }
345 return redirect(getClass());
346 }
347
348
349
350
351 private OptionalEntity<File> getTargetFile(final String fileName) {
352 final File baseDir = new File(getServletContext().getRealPath("/"));
353 final File targetFile = new File(getServletContext().getRealPath(fileName));
354 final List<File> fileList = getAccessibleFileList(baseDir);
355 for (final File file : fileList) {
356 if (targetFile.equals(file)) {
357 return OptionalEntity.of(targetFile);
358 }
359 }
360 return OptionalEntity.empty();
361 }
362
363 private List<File> getAccessibleFileList(final File baseDir) {
364 final List<File> fileList = new ArrayList<>(
365 FileUtils.listFiles(new File(baseDir, "images"), fessConfig.getSupportedUploadedMediaExtentionsAsArray(), true));
366 fileList.addAll(FileUtils.listFiles(new File(baseDir, "css"), fessConfig.getSupportedUploadedCssExtentionsAsArray(), true));
367 fileList.addAll(FileUtils.listFiles(new File(baseDir, "js"), fessConfig.getSupportedUploadedJsExtentionsAsArray(), true));
368 return fileList;
369 }
370
371 private boolean isValidUploadPath(final File file, final File baseDir) {
372 try {
373 final String canonicalFilePath = file.getCanonicalPath();
374 final String canonicalBasePath = baseDir.getCanonicalPath() + File.separator;
375 return canonicalFilePath.startsWith(canonicalBasePath);
376 } catch (final IOException e) {
377 logger.warn("Failed to validate upload path: file={}", file.getAbsolutePath(), e);
378 return false;
379 }
380 }
381
382 private File getJspFile(final String fileName, final String jspType) {
383 try {
384 final String[] values = URLDecoder.decode(fileName, Constants.UTF_8).split(":");
385 if (values.length != 2) {
386 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
387 }
388
389
390 final String virtualHostPath = values[0];
391 if (!isValidVirtualHostPath(virtualHostPath)) {
392 logger.warn("Invalid virtual host path detected: path={}", virtualHostPath);
393 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
394 }
395
396 final String jspFileName = systemHelper.getDesignJspFileName(values[1]);
397 if (jspFileName == null) {
398 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
399 }
400 String path;
401 if ("view".equals(jspType)) {
402 path = "/WEB-INF/" + jspType + virtualHostPath + "/" + jspFileName;
403 } else {
404 path = "/WEB-INF/" + jspType + "/" + jspFileName;
405 }
406 final File jspFile = new File(getServletContext().getRealPath(path));
407
408
409 final File webInfDir = new File(getServletContext().getRealPath("/WEB-INF"));
410 if (!isValidUploadPath(jspFile, webInfDir)) {
411 logger.warn("Path traversal attempt detected in JSP file path: path={}", path);
412 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
413 }
414
415 if (!jspFile.exists()) {
416 throwValidationError(messages -> messages.addErrorsDesignJspFileDoesNotExist(GLOBAL), this::asListHtml);
417 }
418 return jspFile;
419 } catch (final UnsupportedEncodingException e) {
420 throw new FessSystemException("Failed to decode " + fileName, e);
421 }
422 }
423
424 private boolean isValidVirtualHostPath(final String path) {
425
426 if (StringUtil.isBlank(path)) {
427 return true;
428 }
429
430 for (final String validPath : ComponentUtil.getVirtualHostHelper().getVirtualHostPaths()) {
431 if (path.equals(validPath)) {
432 return true;
433 }
434 }
435
436 return "/".equals(path);
437 }
438
439
440
441
442
443 private HtmlResponse asListHtml() {
444 return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class);
445 }
446
447 private HtmlResponse asListHtml(final UploadForm uploadForm) {
448 return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class, setup -> {
449 setup.setup(form -> {
450 copyBeanToBean(uploadForm, form, op -> op.include("designFile", "designFileName"));
451 });
452 });
453 }
454
455 private HtmlResponse asEditHtml(final EditForm form) {
456 return asHtml(path_AdminDesign_AdminDesignEditJsp).renderWith(data -> {
457 data.register("displayFileName", getJspFile(form.fileName, "view").getAbsolutePath());
458 });
459 }
460
461
462
463
464
465
466 public static String decodeJsp(final String value) {
467 return value.replaceAll("<%(?![@-])([\\s\\S]*?)%>", "<%$1%>")
468 .replaceAll("<%=([\\s\\S]*?)%>", "<%=$1%>")
469 .replace(TRY_STATEMENT, "<% try{ %>")
470 .replace(CACHE_AND_SESSION_INVALIDATE_STATEMENT, "<% }catch(Exception e){session.invalidate();} %>");
471 }
472
473
474
475
476
477
478 public static String encodeJsp(final String value) {
479 return value.replace("<% try{ %>", TRY_STATEMENT)
480 .replace("<% }catch(Exception e){session.invalidate();} %>", CACHE_AND_SESSION_INVALIDATE_STATEMENT);
481 }
482 }