diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/MessageDigestUtil.java b/flow-server/src/main/java/com/vaadin/flow/internal/MessageDigestUtil.java index 9103f78b9c8..c479ae29cf3 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/MessageDigestUtil.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/MessageDigestUtil.java @@ -17,6 +17,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -75,6 +76,39 @@ public static byte[] sha256(String string, byte[] salt, Charset charset) { return getSha256(salt).digest(string.getBytes(charset)); } + /** + * Calculates the SHA-256 hash of the given byte array. + * + * @param content + * the byte array to hash + * + * @return sha256 hash string + */ + public static String sha256Hex(byte[] content) { + return sha256Hex(content, null); + } + + /** + * Calculates the SHA-256 hash of the given byte array with the given salt. + * + * @param content + * the byte array to hash + * @param salt + * salt to be added to the calculation + * @return sha256 hash string + */ + public static String sha256Hex(byte[] content, byte[] salt) { + byte[] digest = getSha256(salt).digest(content); + final StringBuilder hexString = new StringBuilder(); + for (int i = 0; i < digest.length; i++) { + final String hex = Integer.toHexString(0xff & digest[i]); + if (hex.length() == 1) + hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } + private static MessageDigest getSha256(byte[] salt) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/NodeInstaller.java b/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/NodeInstaller.java index 85c5edb184a..d10e82fb22d 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/NodeInstaller.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/NodeInstaller.java @@ -34,6 +34,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.vaadin.flow.internal.MessageDigestUtil; import com.vaadin.flow.internal.Pair; import com.vaadin.flow.server.frontend.FrontendUtils; import com.vaadin.flow.server.frontend.FrontendVersion; @@ -55,6 +56,8 @@ public class NodeInstaller { public static final String UNOFFICIAL_NODEJS_DOWNLOAD_ROOT = "https://unofficial-builds.nodejs.org/download/release/"; + public static final String SHA_SUMS_FILE = "SHASUMS256.txt"; + private static final String NODE_WINDOWS = INSTALL_PATH.replaceAll("/", "\\\\") + "\\node.exe"; private static final String NODE_DEFAULT = INSTALL_PATH + "/node"; @@ -64,6 +67,7 @@ public class NodeInstaller { private static final int MAX_DOWNLOAD_ATTEMPS = 5; private static final int DOWNLOAD_ATTEMPT_DELAY = 5; + public static final String ACCEPT_MISSING_SHA = "vaadin.node.download.acceptMissingSHA"; private final Object lock = new Object(); @@ -524,6 +528,8 @@ private void downloadFileIfMissing(URI downloadUrl, File destination, try { fileDownloader.download(downloadUrl, destination, userName, password, null); + + verifyArchive(destination); return; } catch (DownloadException e) { if (i == MAX_DOWNLOAD_ATTEMPS - 1) { @@ -538,10 +544,85 @@ private void downloadFileIfMissing(URI downloadUrl, File destination, try { Thread.sleep(DOWNLOAD_ATTEMPT_DELAY * 1000); } catch (InterruptedException e1) { + Thread.currentThread().interrupt(); + } + } catch (VerificationException ve) { + getLogger().warn( + "SHA256 verification of downloaded node archive failed."); + if (i == MAX_DOWNLOAD_ATTEMPS - 1) { + removeArchiveFile(destination); + throw new DownloadException( + "Failed to download node matching SHA256."); } } + } + } else { + try { + verifyArchive(destination); + } catch (VerificationException de) { + removeArchiveFile(destination); + downloadFileIfMissing(downloadUrl, destination, userName, + password); + } + } + } + private void verifyArchive(File archive) + throws DownloadException, VerificationException { + try { + URI shaSumsURL = nodeDownloadRoot + .resolve(nodeVersion + "/" + SHA_SUMS_FILE); + if ("file".equalsIgnoreCase(shaSumsURL.getScheme())) { + // The file is local so it can't be expected to have a SHA file + return; } + + File shaSums = new File(installDirectory, "node-" + SHA_SUMS_FILE); + + getLogger().debug("Downloading {} to {}", shaSumsURL, shaSums); + + try { + fileDownloader.download(shaSumsURL, shaSums, userName, password, + null); + } catch (DownloadException e) { + if (Boolean.getBoolean(ACCEPT_MISSING_SHA)) { + getLogger().warn( + "Could not verify SHA256 sum of downloaded node in {}. Accepting missing checksum verification as set in '{}' system property.", + archive, ACCEPT_MISSING_SHA); + return; + } else { + getLogger().info( + "Download of {} failed. If failure persists, use system property '{}' to skip verification or download node manually.", + SHA_SUMS_FILE, ACCEPT_MISSING_SHA); + throw e; + } + } + + String archiveSHA256 = MessageDigestUtil + .sha256Hex(Files.readAllBytes(archive.toPath())); + + List sha256sums = Files.readAllLines(shaSums.toPath()); + String archiveTargetSHA256 = sha256sums.stream() + .filter(sum -> sum + .endsWith(archive.getName())) + .map(sum -> sum + .substring(0, + sum.length() - archive.getName().length()) + .trim()) + .findFirst().orElse("-1"); + + shaSums.delete(); + + if (!archiveSHA256.equals(archiveTargetSHA256)) { + getLogger().error( + "Expected SHA256 [{}] for downloaded node archive, got [{}]", + archiveTargetSHA256, archiveSHA256); + throw new VerificationException( + "SHA256 sums did not match for downloaded node"); + } + } catch (IOException e) { + throw new VerificationException("Failed to validate archive hash.", + e); } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/ProxyConfig.java b/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/ProxyConfig.java index e960c819ffa..de61e4c91fc 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/ProxyConfig.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/ProxyConfig.java @@ -70,8 +70,9 @@ public Proxy getProxyForUrl(String requestUrl) { + "development-mode/node-js#proxy-settings-for-downloading-" + "frontend-toolchain for information on proxy configuration."; if (proxies.isEmpty()) { - getLogger().info("No proxies configured. " - + "If you are behind a proxy server, " + docLink); + getLogger().debug( + "No proxies configured. If you are behind a proxy server, {}", + docLink); return null; } final URI uri = URI.create(requestUrl); @@ -80,9 +81,8 @@ public Proxy getProxyForUrl(String requestUrl) { return proxy; } } - getLogger().info( - "Could not find matching proxy for host: {}" + " - " + docLink, - uri.getHost()); + getLogger().info("Could not find matching proxy for host: {} - {}", + uri.getHost(), docLink); return null; } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/VerificationException.java b/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/VerificationException.java new file mode 100644 index 00000000000..2a202828605 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/VerificationException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2000-2024 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.frontend.installer; + +/** + * Exception indicating a failure during downloaded archive verification. + *

+ * For internal use only. May be renamed or removed in a future release. + * + * @since + */ +public final class VerificationException extends Exception { + + /** + * Exceptioon with message. + * + * @param message + * exception message + */ + public VerificationException(String message) { + super(message); + } + + /** + * Exceptioon with message and cause. + * + * @param message + * exception message + * @param cause + * cause for exception + */ + VerificationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java b/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java index 1b5f9c4ba30..477b0330452 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java @@ -182,6 +182,9 @@ public void nodeIsBeingLocated_supportedNodeInstalled_autoUpdateTrue_NodeUpdated @Test public void nodeIsBeingLocated_unsupportedNodeInstalled_defaultNodeVersionInstalledToAlternativeDirectory() throws FrontendUtils.UnknownVersionException, IOException { + Assume.assumeFalse( + "Skipping test on windows until a fake node.exe that isn't caught by Window defender can be created.", + FrontendUtils.isWindows()); // Unsupported node version FrontendStubs.ToolStubInfo nodeStub = FrontendStubs.ToolStubInfo .builder(FrontendStubs.Tool.NODE).withVersion("8.9.3").build(); @@ -204,6 +207,9 @@ public void nodeIsBeingLocated_unsupportedNodeInstalled_defaultNodeVersionInstal @Test public void nodeIsBeingLocated_unsupportedNodeInstalled_fallbackToNodeInstalledToAlternativeDirectory() throws IOException, FrontendUtils.UnknownVersionException { + Assume.assumeFalse( + "Skipping test on windows until a fake node.exe that isn't caught by Window defender can be created.", + FrontendUtils.isWindows()); // Unsupported node version FrontendStubs.ToolStubInfo nodeStub = FrontendStubs.ToolStubInfo .builder(FrontendStubs.Tool.NODE).withVersion("8.9.3").build(); @@ -730,6 +736,8 @@ public void getSuitablePnpm_supportedGlobalVersionInstalled_accepted() { @Test public void getSuitablePnpm_useGlobalPnpm_noPnpmInstalled_throws() { + Assume.assumeFalse("Skipping test on windows.", + FrontendUtils.isWindows()); Optional pnpm = frontendToolsLocator.tryLocateTool("pnpm"); Assume.assumeFalse("Skip this test once globally installed pnpm is " + "discovered", pnpm.isPresent());