Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: validate downloaded node #20821

Merged
merged 5 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
mcollovati marked this conversation as resolved.
Show resolved Hide resolved
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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand All @@ -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();

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
mcollovati marked this conversation as resolved.
Show resolved Hide resolved
}
}

String archiveSHA256 = MessageDigestUtil
.sha256Hex(Files.readAllBytes(archive.toPath()));

List<String> 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");
mcollovati marked this conversation as resolved.
Show resolved Hide resolved

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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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<File> pnpm = frontendToolsLocator.tryLocateTool("pnpm");
Assume.assumeFalse("Skip this test once globally installed pnpm is "
+ "discovered", pnpm.isPresent());
Expand Down
Loading