INT-3796: Configure SFTP Unknown Host Behavior

JIRA: https://jira.spring.io/browse/INT-3796

Previously, the `DefaultSftpSessionFactory` unconditionally accepted keys
from unknown hosts, or when a host key changed.

Permit this behavior to be configured with `allowUnknownHosts` and/or a custom
`UserInfo` properties in the factory.

`allowUknownHosts` is false by default (since 4.2) and true by default in 4.1.x for
backwards compatibility.

INT-3796: Fix Tests

INT-3796: Polishing

Conflicts:
	src/reference/asciidoc/sftp.adoc
	src/reference/asciidoc/whats-new.adoc

Fix `SftpSessionFactoryTests` according to the `allowUnknownKeys = true` in the `4.1.x`
This commit is contained in:
Gary Russell
2015-08-07 13:31:54 -04:00
committed by Artem Bilan
parent 315ed056d8
commit 7d559789ff
5 changed files with 362 additions and 132 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,9 +16,14 @@
package org.springframework.integration.sftp.session;
import java.util.Arrays;
import java.util.Properties;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.core.io.Resource;
import org.springframework.integration.file.remote.session.SessionFactory;
@@ -43,11 +48,22 @@ import com.jcraft.jsch.UserInfo;
* @author Gunnar Hillert
* @author Gary Russell
* @author David Liu
* @author Pat Turner
*
* @since 2.0
*/
public class DefaultSftpSessionFactory implements SessionFactory<LsEntry>, SharedSessionCapable {
private static final Log logger = LogFactory.getLog(DefaultSftpSessionFactory.class);
private final ReadWriteLock sharedSessionLock = new ReentrantReadWriteLock();
private final UserInfo userInfoWrapper = new UserInfoWrapper();
private final JSch jsch;
private final boolean isSharedSession;
private volatile String host;
private volatile int port = 22; // the default
@@ -80,13 +96,11 @@ public class DefaultSftpSessionFactory implements SessionFactory<LsEntry>, Share
private volatile Boolean enableDaemonThread;
private final JSch jsch;
private final boolean isSharedSession;
private volatile JSchSessionWrapper sharedJschSession;
private final ReentrantReadWriteLock sharedSessionLock = new ReentrantReadWriteLock();
private volatile UserInfo userInfo;
private volatile boolean allowUnknownKeys = true;
public DefaultSftpSessionFactory() {
@@ -159,8 +173,10 @@ public class DefaultSftpSessionFactory implements SessionFactory<LsEntry>, Share
}
/**
* Specifies the filename that will be used to create a host key repository.
* The resulting file has the same format as OpenSSH's known_hosts file.
* Specifies the filename that will be used for a host key repository.
* The file has the same format as OpenSSH's known_hosts file.
* Required if {@link #setAllowUnknownKeys(boolean) allowUnknownKeys} is
* false (default).
*
* @param knownHosts The known hosts.
*
@@ -311,6 +327,42 @@ public class DefaultSftpSessionFactory implements SessionFactory<LsEntry>, Share
this.enableDaemonThread = enableDaemonThread;
}
/**
* Provide a {@link UserInfo} which exposes control over dealing with new keys or key
* changes. As Spring Integration will not normally allow user interaction, the
* implementation must respond to Jsch calls in a suitable way.
* <p>
* Jsch calls {@link UserInfo#promptYesNo(String)} when connecting to an unknown host,
* or when a known host's key has changed (see {@link #setKnownHosts(String)
* knownHosts}). Generally, it should return false as returning true will accept all
* new keys or key changes.
* <p>
* If no {@link UserInfo} is provided, the behavior is defined by
* {@link #setAllowUnknownKeys(boolean) allowUnknownKeys}.
* <p>
* If {@link #setPassword(String) setPassword} is invoked with a non-null password, it will
* override any password in the supplied {@link UserInfo}.
*
* @param userInfo the UserInfo.
* @see com.jcraft.jsch.Session#setUserInfo(com.jcraft.jsch.UserInfo)
* @since 4.1.7
*/
public void setUserInfo(UserInfo userInfo) {
this.userInfo = userInfo;
}
/**
* When no {@link UserInfo} has been provided, set to true to unconditionally allow
* connecting to an unknown host or when a host's key has changed (see
* {@link #setKnownHosts(String) knownHosts}). Default false (since 4.2).
* Set to true if a knownHosts file is not provided.
*
* @param allowUnknownKeys true to allow connecting to unknown hosts.
* @since 4.1.7
*/
public void setAllowUnknownKeys(boolean allowUnknownKeys) {
this.allowUnknownKeys = allowUnknownKeys;
}
@Override
public SftpSession getSession() {
@@ -383,7 +435,7 @@ public class DefaultSftpSessionFactory implements SessionFactory<LsEntry>, Share
if (StringUtils.hasText(this.password)) {
jschSession.setPassword(this.password);
}
jschSession.setUserInfo(new OptimisticUserInfoImpl(this.password));
jschSession.setUserInfo(this.userInfoWrapper);
try {
if (proxy != null){
@@ -428,50 +480,119 @@ public class DefaultSftpSessionFactory implements SessionFactory<LsEntry>, Share
}
/**
* this is a simple, optimistic implementation of the UserInfo interface.
* It returns in the positive where possible and handles interactive authentication
* (i.e. 'Please enter your password: ' prompts are dispatched automatically).
* Wrapper class will delegate calls to a configured {@link UserInfo}, providing
* sensible defaults if null. As the password is configured in this Factory, the
* wrapper will return the factory's configured password and only delegate to the
* UserInfo if null.
* @since 4.1.7
*/
private static class OptimisticUserInfoImpl implements UserInfo, UIKeyboardInteractive {
private class UserInfoWrapper implements UserInfo, UIKeyboardInteractive {
private final String password;
/**
* Convenience to check whether enclosing factory's UserInfo is configured.
* @return true if there's a delegate.
*/
private boolean hasDelegate() {
return getDelegate() != null;
}
public OptimisticUserInfoImpl(String password) {
this.password = password;
/**
* Convenience to retrieve enclosing factory's UserInfo.
* @return
*/
private UserInfo getDelegate() {
return DefaultSftpSessionFactory.this.userInfo;
}
@Override
public String getPassphrase() {
return null; // pass
if (hasDelegate()) {
return getDelegate().getPassphrase();
}
else {
if (logger.isDebugEnabled()) {
logger.debug("No UserInfo provided for passphrase, returning: null");
}
return null;
}
}
@Override
public String getPassword() {
return this.password;
if (hasDelegate()) {
if (DefaultSftpSessionFactory.this.password != null) {
logger.debug("Password is obtained from the factory, not the supplied UserInfo");
}
else {
return getDelegate().getPassword();
}
}
return DefaultSftpSessionFactory.this.password;
}
@Override
public boolean promptPassphrase(String string) {
return true;
public boolean promptPassword(String message) {
if (hasDelegate()) {
return getDelegate().promptPassword(message);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("No UserInfo provided - " + message + ", returning: true");
}
return true;
}
}
@Override
public boolean promptPassword(String string) {
return true;
public boolean promptPassphrase(String message) {
if (hasDelegate()) {
return getDelegate().promptPassphrase(message);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("No UserInfo provided - " + message + ", returning: true");
}
return true;
}
}
@Override
public boolean promptYesNo(String string) {
return true;
public boolean promptYesNo(String message) {
if (hasDelegate()) {
return getDelegate().promptYesNo(message);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("No UserInfo provided - " + message + ", returning:"
+ DefaultSftpSessionFactory.this.allowUnknownKeys);
}
return DefaultSftpSessionFactory.this.allowUnknownKeys;
}
}
@Override
public void showMessage(String string) {
public void showMessage(String message) {
if (hasDelegate()) {
getDelegate().showMessage(message);
}
else {
logger.debug(message);
}
}
@Override
public String[] promptKeyboardInteractive(String destination,
String name, String instruction, String[] prompt, boolean[] echo) {
public String[] promptKeyboardInteractive(String destination, String name, String instruction, String[] prompt,
boolean[] echo) {
if (hasDelegate()) {
if (getDelegate() instanceof UIKeyboardInteractive) {
return ((UIKeyboardInteractive) getDelegate()).promptKeyboardInteractive(destination, name,
instruction, prompt, echo);
}
}
if (logger.isDebugEnabled()) {
logger.debug("No UserInfo provided - " + destination + ":" + name + ":" + instruction + ":"
+ Arrays.asList(prompt) + ":" + Arrays.asList(echo));
}
return null;
}
}

View File

@@ -192,6 +192,7 @@ public class TestSftpServer implements InitializingBean, DisposableBean {
factory.setPort(this.port);
factory.setUser("foo");
factory.setPassword("foo");
factory.setAllowUnknownKeys(true);
return factory;
}

View File

@@ -1,105 +0,0 @@
/*
* Copyright 2014 the original author or authors.
*
* 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 org.springframework.integration.sftp.session;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.net.ConnectException;
import org.apache.sshd.SshServer;
import org.apache.sshd.server.PasswordAuthenticator;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.server.session.ServerSession;
import org.junit.Test;
import org.springframework.integration.test.util.SocketUtils;
import com.jcraft.jsch.JSchException;
/**
* @author Gary Russell
* @since 3.0.2
*
*/
public class INT3305Tests {
/*
* Verify the socket is closed if the channel.connect() fails.
*/
@Test
public void testConnectFailSocketOpen() throws Exception {
final int port = SocketUtils.findAvailableServerSocket();
SshServer server = SshServer.setUpDefaultServer();
try {
server.setPasswordAuthenticator(new PasswordAuthenticator() {
@Override
public boolean authenticate(String arg0, String arg1, ServerSession arg2) {
return true;
}
});
server.setPort(port);
server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider("hostkey.ser"));
server.start();
DefaultSftpSessionFactory f = new DefaultSftpSessionFactory();
f.setHost("localhost");
f.setPort(port);
f.setUser("user");
f.setPassword("pass");
int n = 0;
while (true) {
try {
f.getSession();
fail("Expected Exception");
}
catch (Exception e) {
if (e instanceof IllegalStateException && "failed to create SFTP Session".equals(e.getMessage())) {
if (e.getCause() instanceof IllegalStateException) {
if (e.getCause().getCause() instanceof JSchException) {
if (e.getCause().getCause().getCause() instanceof ConnectException) {
assertTrue("Server failed to start in 10 seconds", n++ < 100);
Thread.sleep(100);
continue;
}
}
}
}
assertThat(e, instanceOf(IllegalStateException.class));
assertThat(e.getCause(), instanceOf(IllegalStateException.class));
assertThat(e.getCause().getMessage(), equalTo("failed to connect"));
break;
}
}
n = 0;
while (n++ < 100 && server.getActiveSessions().size() > 0) {
Thread.sleep(100);
}
assertEquals(0, server.getActiveSessions().size());
}
finally {
server.stop(true);
}
}
}

View File

@@ -102,6 +102,7 @@ public class SftpServerTests {
f.setPort(port);
f.setUser("user");
f.setPassword("pass");
f.setAllowUnknownKeys(true);
Session<LsEntry> session = f.getSession();
doTest(server, session);
}
@@ -153,6 +154,7 @@ public class SftpServerTests {
f.setHost("localhost");
f.setPort(port);
f.setUser("user");
f.setAllowUnknownKeys(true);
InputStream stream = new ClassPathResource("id_rsa").getInputStream();
f.setPrivateKey(new ByteArrayResource(StreamUtils.copyToByteArray(stream)));
Session<LsEntry> session = f.getSession();

View File

@@ -0,0 +1,211 @@
/*
* Copyright 2014-2015 the original author or authors.
*
* 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 org.springframework.integration.sftp.session;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.ConnectException;
import java.security.PublicKey;
import java.util.Arrays;
import org.apache.sshd.SshServer;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.server.Command;
import org.apache.sshd.server.PasswordAuthenticator;
import org.apache.sshd.server.PublickeyAuthenticator;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.server.session.ServerSession;
import org.apache.sshd.server.sftp.SftpSubsystem;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.integration.test.util.SocketUtils;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.UserInfo;
/**
* @author Gary Russell
* @since 3.0.2
*/
public class SftpSessionFactoryTests {
/*
* Verify the socket is closed if the channel.connect() fails.
* INT-3305
*/
@Test
public void testConnectFailSocketOpen() throws Exception {
final int port = SocketUtils.findAvailableServerSocket();
SshServer server = SshServer.setUpDefaultServer();
try {
server.setPasswordAuthenticator(new PasswordAuthenticator() {
@Override
public boolean authenticate(String arg0, String arg1, ServerSession arg2) {
return true;
}
});
server.setPort(port);
server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider("hostkey.ser"));
server.start();
DefaultSftpSessionFactory f = new DefaultSftpSessionFactory();
f.setHost("localhost");
f.setPort(port);
f.setUser("user");
f.setPassword("pass");
int n = 0;
while (true) {
try {
f.getSession();
fail("Expected Exception");
}
catch (Exception e) {
if (e instanceof IllegalStateException && "failed to create SFTP Session".equals(e.getMessage())) {
if (e.getCause() instanceof IllegalStateException) {
if (e.getCause().getCause() instanceof JSchException) {
if (e.getCause().getCause().getCause() instanceof ConnectException) {
assertTrue("Server failed to start in 10 seconds", n++ < 100);
Thread.sleep(100);
continue;
}
}
}
}
assertThat(e, instanceOf(IllegalStateException.class));
assertThat(e.getCause(), instanceOf(IllegalStateException.class));
assertThat(e.getCause().getMessage(), equalTo("failed to connect"));
break;
}
}
n = 0;
while (n++ < 100 && server.getActiveSessions().size() > 0) {
Thread.sleep(100);
}
assertEquals(0, server.getActiveSessions().size());
}
finally {
server.stop(true);
}
}
@Test
public void testDefaultUserInfoFalse() throws Exception {
SshServer server = SshServer.setUpDefaultServer();
try {
DefaultSftpSessionFactory f = createServerAndClient(server);
f.setAllowUnknownKeys(false);
expectReject(f);
}
finally {
server.stop(true);
}
}
@Test
public void testDefaultUserInfoTrue() throws Exception {
SshServer server = SshServer.setUpDefaultServer();
try {
DefaultSftpSessionFactory f = createServerAndClient(server);
f.getSession().close();
}
finally {
server.stop(true);
}
}
@Test
public void testCustomUserInfoFalse() throws Exception {
SshServer server = SshServer.setUpDefaultServer();
try {
DefaultSftpSessionFactory f = createServerAndClient(server);
UserInfo userInfo = mock(UserInfo.class);
when(userInfo.promptYesNo(anyString())).thenReturn(false);
f.setUserInfo(userInfo);
expectReject(f);
}
finally {
server.stop(true);
}
}
private void expectReject(DefaultSftpSessionFactory f) {
try {
f.getSession().close();
fail("Expected Exception");
}
catch (Exception e) {
assertThat(e, instanceOf(IllegalStateException.class));
assertThat(e.getCause(), instanceOf(IllegalStateException.class));
assertThat(e.getCause().getCause(), instanceOf(JSchException.class));
assertThat(e.getCause().getCause().getMessage(), containsString("reject HostKey"));
}
}
@Test
public void testCustomUserInfoTrue() throws Exception {
SshServer server = SshServer.setUpDefaultServer();
try {
DefaultSftpSessionFactory f = createServerAndClient(server);
UserInfo userInfo = mock(UserInfo.class);
when(userInfo.promptYesNo(anyString())).thenReturn(true);
f.setUserInfo(userInfo);
f.getSession().close();
}
finally {
server.stop(true);
}
}
@SuppressWarnings("unchecked")
private DefaultSftpSessionFactory createServerAndClient(SshServer server) throws IOException {
final int port = SocketUtils.findAvailableServerSocket();
server.setPublickeyAuthenticator(new PublickeyAuthenticator() {
@Override
public boolean authenticate(String username, PublicKey key, ServerSession session) {
return true;
}
});
server.setPort(port);
server.setSubsystemFactories(Arrays.<NamedFactory<Command>>asList(new SftpSubsystem.Factory()));
server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider("hostkey.ser"));
server.start();
DefaultSftpSessionFactory f = new DefaultSftpSessionFactory();
f.setHost("localhost");
f.setPort(port);
f.setUser("user");
Resource privateKey = new ClassPathResource("id_rsa");
f.setPrivateKey(privateKey);
return f;
}
}