From aa8050b1236e5362a5dcaa02aedd3c491a8ed28b Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 14 Oct 2026 11:32:39 +0200 Subject: [PATCH 1/7] CAMEL-25644: camel-ssh - match known_hosts entries by exact host pattern ResourceBasedSSHKeyVerifier parsed the known_hosts resource with its own parser: it compared the host field with String.contains, used only the first matching line, did not tell [host]:port entries from plain ones, skipped comments, markers and hashed entries, and looked up an endpoint configured with an address by the name of a reverse lookup. Read and match the entries with Apache MINA SSHD's KnownHostEntry against the host and port configured on the endpoint, consider every entry of the host, reject a key listed in a matching @revoked entry, and keep rejecting a host that has entries but presents a key none of them lists, whatever failOnUnknownHost is set to. Document the supported format and add the upgrade-guide notes for 4.24, 4.22.2 and 4.18.6. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../apache/camel/catalog/components/ssh.json | 8 +- .../camel/catalog/docs/ssh-component.adoc | 61 +++++ .../org/apache/camel/component/ssh/ssh.json | 8 +- .../src/main/docs/ssh-component.adoc | 61 +++++ .../ssh/ResourceBasedSSHKeyVerifier.java | 178 ++++++--------- .../camel/component/ssh/SshConfiguration.java | 12 +- .../ssh/ResourceBasedSSHKeyVerifierTest.java | 216 +++++++++++++++--- .../ssh/SshComponentKnownHostEdDSATest.java | 13 +- .../ssh/SshComponentKnownHostTest.java | 61 ++++- .../ssh/SshComponentTestSupport.java | 24 ++ .../src/test/resources/known_hosts_eddsa | 1 - .../src/test/resources/known_hosts_valid | 1 - .../pages/camel-4x-upgrade-guide-4_18.adoc | 21 ++ .../pages/camel-4x-upgrade-guide-4_22.adoc | 21 ++ .../pages/camel-4x-upgrade-guide-4_24.adoc | 21 ++ .../dsl/SshComponentBuilderFactory.java | 13 +- .../dsl/SshEndpointBuilderFactory.java | 60 +++-- 17 files changed, 597 insertions(+), 183 deletions(-) delete mode 100644 components/camel-ssh/src/test/resources/known_hosts_eddsa delete mode 100644 components/camel-ssh/src/test/resources/known_hosts_valid diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json index 7b56affb33895..a846165593e43 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json @@ -47,11 +47,11 @@ "certResource": { "index": 19, "kind": "property", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 20, "kind": "property", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 21, "kind": "property", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." }, + "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, "kex": { "index": 23, "kind": "property", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 24, "kind": "property", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 25, "kind": "property", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 27, "kind": "property", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 28, "kind": "property", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 29, "kind": "property", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, @@ -102,11 +102,11 @@ "certResource": { "index": 35, "kind": "parameter", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 36, "kind": "parameter", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 37, "kind": "parameter", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." }, + "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, "kex": { "index": 39, "kind": "parameter", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 40, "kind": "parameter", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 41, "kind": "parameter", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 43, "kind": "parameter", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 44, "kind": "parameter", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 45, "kind": "parameter", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc index 698ec5f305daa..ec1f5ba0c3887 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc @@ -163,6 +163,67 @@ YAML:: An example of using Public Key authentication is provided in `examples/camel-example-ssh-security`. +=== Server host key verification + +The SSH Component verifies the host key of the SSH server only when the `knownHostsResource` option +is set. It refers to a file in the OpenSSH `known_hosts` format, such as the output of `ssh-keyscan`. +Set `failOnUnknownHost` to `true` as well, so that a server without any entry in that file is rejected. +When it is `false` (the default), such a server is accepted and a warning is logged. + +The entries are matched as OpenSSH matches them: + +* The host name or address configured on the endpoint is compared with each comma-separated host +pattern of an entry, ignoring case. Wildcards (`*` and `?`), negated patterns (`!`) and hashed +entries are supported. The configured value is used as is: an endpoint configured with an address +only uses the entries for that address. +* An entry without a port applies to port 22 only. For another port, use the `[host]:port` form, +for example `[db.example.com]:2222`. +* The server is accepted when one of the entries for its host lists the key it presents. A host can be +listed with several keys, one per line. +* The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when +its host has entries but none of them lists that key, whatever the value of `failOnUnknownHost`. +* `@cert-authority` entries (OpenSSH host certificates) are not supported and are ignored. + +The following route verifies the host key of a server listening on port 2222 against a +`known_hosts` file that contains the line `[db.example.com]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...`. + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("direct:status") + .to("ssh://scott@db.example.com:2222?certResource=classpath:test_rsa&knownHostsResource=file:/etc/camel/known_hosts&failOnUnknownHost=true"); +---- + +XML:: ++ +[source,xml] +---- + + + + +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: direct:status + steps: + - to: + uri: ssh://scott@db.example.com:2222 + parameters: + certResource: "classpath:test_rsa" + knownHostsResource: "file:/etc/camel/known_hosts" + failOnUnknownHost: true +---- +==== + [[SSH-CertificateDependencies]] === Certificate Dependencies diff --git a/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json b/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json index 7b56affb33895..a846165593e43 100644 --- a/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json +++ b/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json @@ -47,11 +47,11 @@ "certResource": { "index": 19, "kind": "property", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 20, "kind": "property", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 21, "kind": "property", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." }, + "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, "kex": { "index": 23, "kind": "property", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 24, "kind": "property", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 25, "kind": "property", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 27, "kind": "property", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 28, "kind": "property", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 29, "kind": "property", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, @@ -102,11 +102,11 @@ "certResource": { "index": 35, "kind": "parameter", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 36, "kind": "parameter", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 37, "kind": "parameter", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." }, + "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, "kex": { "index": 39, "kind": "parameter", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 40, "kind": "parameter", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 41, "kind": "parameter", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 43, "kind": "parameter", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 44, "kind": "parameter", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 45, "kind": "parameter", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, diff --git a/components/camel-ssh/src/main/docs/ssh-component.adoc b/components/camel-ssh/src/main/docs/ssh-component.adoc index 698ec5f305daa..ec1f5ba0c3887 100644 --- a/components/camel-ssh/src/main/docs/ssh-component.adoc +++ b/components/camel-ssh/src/main/docs/ssh-component.adoc @@ -163,6 +163,67 @@ YAML:: An example of using Public Key authentication is provided in `examples/camel-example-ssh-security`. +=== Server host key verification + +The SSH Component verifies the host key of the SSH server only when the `knownHostsResource` option +is set. It refers to a file in the OpenSSH `known_hosts` format, such as the output of `ssh-keyscan`. +Set `failOnUnknownHost` to `true` as well, so that a server without any entry in that file is rejected. +When it is `false` (the default), such a server is accepted and a warning is logged. + +The entries are matched as OpenSSH matches them: + +* The host name or address configured on the endpoint is compared with each comma-separated host +pattern of an entry, ignoring case. Wildcards (`*` and `?`), negated patterns (`!`) and hashed +entries are supported. The configured value is used as is: an endpoint configured with an address +only uses the entries for that address. +* An entry without a port applies to port 22 only. For another port, use the `[host]:port` form, +for example `[db.example.com]:2222`. +* The server is accepted when one of the entries for its host lists the key it presents. A host can be +listed with several keys, one per line. +* The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when +its host has entries but none of them lists that key, whatever the value of `failOnUnknownHost`. +* `@cert-authority` entries (OpenSSH host certificates) are not supported and are ignored. + +The following route verifies the host key of a server listening on port 2222 against a +`known_hosts` file that contains the line `[db.example.com]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...`. + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("direct:status") + .to("ssh://scott@db.example.com:2222?certResource=classpath:test_rsa&knownHostsResource=file:/etc/camel/known_hosts&failOnUnknownHost=true"); +---- + +XML:: ++ +[source,xml] +---- + + + + +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: direct:status + steps: + - to: + uri: ssh://scott@db.example.com:2222 + parameters: + certResource: "classpath:test_rsa" + knownHostsResource: "file:/etc/camel/known_hosts" + failOnUnknownHost: true +---- +==== + [[SSH-CertificateDependencies]] === Certificate Dependencies diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java index 1b5556466d72e..eb310ca0bf4c5 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java @@ -18,30 +18,39 @@ import java.io.IOException; import java.io.InputStream; -import java.math.BigInteger; import java.net.InetSocketAddress; import java.net.SocketAddress; -import java.security.NoSuchAlgorithmException; +import java.security.GeneralSecurityException; import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.util.Base64; -import java.util.LinkedList; +import java.util.Collections; import java.util.List; import org.apache.camel.CamelContext; import org.apache.camel.support.ResourceHelper; import org.apache.camel.util.IOHelper; +import org.apache.sshd.client.config.hosts.KnownHostEntry; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; -import org.bouncycastle.util.Arrays; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.apache.sshd.common.util.net.SshdSocketAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * ServerKeyVerifier that takes a camel resource as input file to validate the server key against. - * + *

+ * The resource uses the OpenSSH known_hosts format. The entries are matched against the host name (or address) and port + * the client connects to, as configured on the endpoint: host patterns are matched as a whole, wildcard, negated and + * hashed patterns are supported, and an entry without a port only applies to port 22. The server is accepted when one + * of the matching entries holds the key it presents, and rejected when that key is marked as {@code @revoked} or when + * the host has entries but none of them holds that key. Only a host without any matching entry is subject to + * {@code failOnUnknownHost}. Host certificates ({@code @cert-authority} entries) are not supported. */ public class ResourceBasedSSHKeyVerifier implements ServerKeyVerifier { + private static final String MARKER_REVOKED = "revoked"; + private static final String MARKER_CERT_AUTHORITY = "cert-authority"; + protected final Logger log = LoggerFactory.getLogger(getClass()); private CamelContext camelContext; @@ -62,22 +71,53 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR @Override public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress remoteAddress, PublicKey serverKey) { log.debug("Trying to find known_hosts file {}", knownHostsResource); + List entries = Collections.emptyList(); InputStream knownHostsInputStream = null; try { knownHostsInputStream = ResourceHelper.resolveMandatoryResourceAsInputStream(camelContext, knownHostsResource); - List possibleTokens = getKnownHostsFileTokensForSocketAddress(remoteAddress); - log.debug("Trying to match PublicKey against provided known_hosts file"); - PublicKey matchingKey = findKeyForServerToken(knownHostsInputStream, possibleTokens); - if (matchingKey != null) { - log.debug("Found PublicKey match for server"); - return Arrays.areEqual(matchingKey.getEncoded(), serverKey.getEncoded()); - } + entries = KnownHostEntry.readKnownHostEntries(knownHostsInputStream, false); } catch (IOException ioException) { log.debug(String.format("Could not find known_hosts file %s", knownHostsResource), ioException); } finally { IOHelper.close(knownHostsInputStream); } + + SshdSocketAddress server = toServerAddress(remoteAddress); + boolean hostListed = false; + boolean keyListed = false; + if (server != null) { + log.debug("Trying to match PublicKey of {} against provided known_hosts file", server); + for (KnownHostEntry entry : entries) { + if (!entry.isHostMatch(server.getHostName(), server.getPort()) + || MARKER_CERT_AUTHORITY.equals(entry.getMarker())) { + continue; + } + boolean sameKey = isSameKey(sshClientSession, entry, serverKey); + if (MARKER_REVOKED.equals(entry.getMarker())) { + if (sameKey) { + log.warn("The key presented by {} is marked as revoked in the known_hosts file {}", server, + knownHostsResource); + return false; + } + } else { + hostListed = true; + if (sameKey) { + keyListed = true; + } + } + } + } + + if (keyListed) { + log.debug("Found PublicKey match for server {}", server); + return true; + } + if (hostListed) { + log.warn("The key presented by {} does not match any key listed for it in the known_hosts file {}", server, + knownHostsResource); + return false; + } if (failOnUnknownHost) { log.warn("Could not find matching key for client session, connection will fail due to configuration"); return false; @@ -88,105 +128,31 @@ public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress rem } } - private PublicKey findKeyForServerToken(InputStream knownHostsInputStream, List possibleTokens) { - String knowHostsLines = camelContext.getTypeConverter().convertTo(String.class, knownHostsInputStream); - if (knowHostsLines == null) { - log.warn("Could not read from the known_hosts file input stream"); - return null; - } - - for (String s : knowHostsLines.split("\n")) { - String[] parts = s.split(" "); - if (parts.length != 3) { - log.warn("Found malformed entry in known_hosts file"); - continue; - } - String entry = parts[0]; - String key = parts[2]; - for (String serverToken : possibleTokens) { - if (entry.contains(serverToken)) { - try { - return loadKey(key); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - log.warn(String.format("Could not load key for server token %s", entry), e); - } - } + private boolean isSameKey(ClientSession sshClientSession, KnownHostEntry entry, PublicKey serverKey) { + String keyType = entry.getKeyEntry().getKeyType(); + try { + PublicKey key = entry.getKeyEntry().resolvePublicKey(sshClientSession, PublicKeyEntryResolver.IGNORING); + if (key == null) { + log.warn("Unsupported key type {} in the known_hosts file {}", keyType, knownHostsResource); + return false; } + return KeyUtils.compareKeys(key, serverKey); + } catch (IOException | GeneralSecurityException | RuntimeException e) { + // a key that cannot be decoded cannot be the key the server presented + log.warn("Could not load a {} key from the known_hosts file {}", keyType, knownHostsResource, e); + return false; } - return null; - } - - private List getKnownHostsFileTokensForSocketAddress(SocketAddress remoteAddress) { - List returnList = new LinkedList<>(); - if (remoteAddress instanceof InetSocketAddress) { - InetSocketAddress inetSocketAddress = (InetSocketAddress) remoteAddress; - - String hostName = inetSocketAddress.getHostName(); - String ipAddress = inetSocketAddress.getAddress().getHostAddress(); - String remotePort = String.valueOf(inetSocketAddress.getPort()); - - returnList.add(hostName); - returnList.add("[" + hostName + "]:" + remotePort); - returnList.add(ipAddress); - returnList.add("[" + ipAddress + "]:" + remotePort); - } - - return returnList; } /* - * Decode the public key string, which is a base64 encoded string that consists - * of multiple parts: 1. public key type (ssh-rsa, ssh-dss, ...) 2. binary key - * data (May consists of multiple parts) - * - * Each part is composed by two sub-parts 1. Length of the part (4 bytes) 2. - * Binary part (length as defined by 1.) - * - * Uses SSHPublicKeyHolder to construct the actual PublicKey Object - * - * Note: Currently only supports RSA and DSA Public keys as required by - * https://tools.ietf.org/html/rfc4253#section-6.6 - * + * The host name or address and the port the client connected to. The host is used exactly as given, never + * resolved, so an endpoint configured with an address is not looked up by the name a reverse lookup returns. */ - PublicKey loadKey(String key) throws NoSuchAlgorithmException, InvalidKeySpecException { - SSHPublicKeyHolder sshPublicKeyHolder = new SSHPublicKeyHolder(); - - byte[] keyByteArray = Base64.getDecoder().decode(key); - int keyByteArrayCursor = 0; - - byte[] tmpData = new byte[4]; - int tmpCursor = 0; - - boolean getLengthMode = true; - while (keyByteArrayCursor < keyByteArray.length) { - if (getLengthMode) { - if (tmpCursor < 4) { - tmpData[tmpCursor] = keyByteArray[keyByteArrayCursor]; - tmpCursor++; - keyByteArrayCursor++; - continue; - } else { - tmpCursor = 0; - getLengthMode = false; - tmpData = new byte[byteArrayToInt(tmpData)]; - } - } - tmpData[tmpCursor] = keyByteArray[keyByteArrayCursor]; - tmpCursor++; - keyByteArrayCursor++; - if (tmpCursor == tmpData.length) { - sshPublicKeyHolder.push(tmpData); - getLengthMode = true; - tmpData = new byte[4]; - tmpCursor = 0; - } + private static SshdSocketAddress toServerAddress(SocketAddress remoteAddress) { + if (remoteAddress instanceof InetSocketAddress || remoteAddress instanceof SshdSocketAddress) { + return SshdSocketAddress.toSshdSocketAddress(remoteAddress); } - - return sshPublicKeyHolder.toPublicKey(); - } - - private int byteArrayToInt(byte[] tmpData) { - return new BigInteger(tmpData).intValue(); + return null; } } diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java index 4a9f7bf282169..76e9da3b80f97 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java @@ -56,11 +56,14 @@ public class SshConfiguration implements Cloneable { private long timeout = 30000; @UriParam(label = "security", security = "insecure:ssl", description = "Sets the resource path for a known_hosts file used to verify the SSH server host key." + + " The file uses the OpenSSH known_hosts format, and its entries are matched against the" + + " host and port configured on the endpoint." + " When not set, the client does not verify the server host key against a known_hosts file.") private String knownHostsResource; @UriParam(label = "security", defaultValue = "false", security = "insecure:ssl", insecureValue = "false", - description = "Specifies whether a connection to an unknown SSH server host key should fail. When false," - + " host keys that are not present in the known_hosts resource are accepted.") + description = "Specifies whether a connection to a server whose host has no entry in the known_hosts" + + " resource should fail. When false, such a server is accepted. A server whose host has" + + " entries is always rejected when it presents a key that none of them lists.") private boolean failOnUnknownHost; @UriParam(label = "advanced", defaultValue = Channel.CHANNEL_EXEC) private String channelType = Channel.CHANNEL_EXEC; @@ -301,8 +304,9 @@ public boolean isFailOnUnknownHost() { } /** - * Specifies whether a connection to an unknown host should fail or not. This value is only checked when the - * property knownHosts is set. + * Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail or + * not. This value is only checked when the property knownHostsResource is set. A server whose host has entries is + * always rejected when it presents a key that none of them lists. * * @param failOnUnknownHost boolean flag, whether a connection to an unknown host should fail */ diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java index f383150e1b057..bde1e4794d5f5 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java @@ -16,47 +16,203 @@ */ package org.apache.camel.component.ssh; -import java.io.IOException; import java.io.InputStream; -import java.security.NoSuchAlgorithmException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPairGenerator; import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.util.LinkedHashMap; -import java.util.Map; +import java.security.spec.ECGenParameterSpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.camel.CamelContext; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.util.io.IoUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourceBasedSSHKeyVerifierTest { -public class ResourceBasedSSHKeyVerifierTest { + @TempDir + Path dir; - public static final Logger LOG = LoggerFactory.getLogger(ResourceBasedSSHKeyVerifierTest.class); + private CamelContext context; + private PublicKey serverKey; + private PublicKey otherKey; + + @BeforeEach + void setUp() throws Exception { + context = new DefaultCamelContext(); + context.start(); + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + serverKey = generator.generateKeyPair().getPublic(); + otherKey = generator.generateKeyPair().getPublic(); + } + + @AfterEach + void tearDown() { + context.stop(); + } @Test - public void verifyAllTypesOfPublicKeys() throws IOException { - final Map keys = new LinkedHashMap<>(); - InputStream knownHosts = getClass().getResourceAsStream("/known_hosts_types"); - assertNotNull(knownHosts); - IoUtils.readAllLines(knownHosts).forEach(l -> { - String[] tab = l.split(" "); - if (l.startsWith("#")) { - return; - } - keys.put(tab[1], tab[2]); - }); - - ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(null, null); - keys.forEach((type, data) -> { - LOG.info("Checking key {}", type); - try { - PublicKey publicKey = verifier.loadKey(data); - assertNotNull(publicKey); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new RuntimeException(e.getMessage(), e); + void acceptsTheKeyListedForTheHost() throws Exception { + assertTrue(verify(true, "db.example.com", 22, serverKey, entry("db.example.com", serverKey))); + assertTrue(verify(true, "db.example.com", 22, serverKey, entry("web.example.com,db.example.com", serverKey))); + } + + @Test + void doesNotUseTheEntryOfAHostWhoseNameContainsTheHostName() throws Exception { + assertFalse(verify(true, "db.example.com", 22, otherKey, entry("old-db.example.com", otherKey))); + assertFalse(verify(true, "db.example.com", 22, otherKey, entry("db.example.com.old", otherKey))); + } + + @Test + void doesNotUseTheEntryOfAnAddressContainingTheAddress() throws Exception { + String knownHosts = entry("10.0.0.12", otherKey) + entry("10.0.0.1", serverKey); + assertFalse(verify(true, "10.0.0.1", 22, otherKey, knownHosts)); + assertTrue(verify(true, "10.0.0.1", 22, serverKey, knownHosts)); + assertFalse(verify(true, "10.0.0.1", 22, otherKey, entry("110.0.0.1", otherKey))); + } + + @Test + void matchesThePort() throws Exception { + String knownHosts = entry("[db.example.com]:2222", otherKey); + assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts)); + assertFalse(verify(true, "db.example.com", 222, otherKey, knownHosts)); + assertTrue(verify(true, "db.example.com", 2222, otherKey, knownHosts)); + // an entry without a port only applies to the default port + assertFalse(verify(true, "db.example.com", 2222, serverKey, entry("db.example.com", serverKey))); + } + + @Test + void matchesWildcardNegatedAndUpperCasePatterns() throws Exception { + assertTrue(verify(true, "db.example.com", 22, serverKey, entry("*.example.com", serverKey))); + assertTrue(verify(true, "db.example.com", 22, serverKey, entry("DB.EXAMPLE.COM", serverKey))); + String knownHosts = entry("!db.example.com,*.example.com", otherKey); + assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts)); + assertTrue(verify(true, "www.example.com", 22, otherKey, knownHosts)); + } + + @Test + void matchesHashedEntries() throws Exception { + String knownHosts = entry(hashed("db.example.com"), serverKey); + assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts)); + assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts)); + assertFalse(verify(true, "www.example.com", 22, serverKey, knownHosts)); + } + + @Test + void considersEveryEntryOfTheHost() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + PublicKey rsaKey = generator.generateKeyPair().getPublic(); + String knownHosts = entry("db.example.com", rsaKey) + entry("db.example.com", serverKey); + assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts)); + assertTrue(verify(true, "db.example.com", 22, rsaKey, knownHosts)); + } + + @Test + void readsCommentsAndCrlfLineEndings() throws Exception { + String knownHosts = "# known hosts\r\n" + "db.example.com " + PublicKeyEntry.toString(serverKey) + " db key\r\n"; + assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts)); + } + + @Test + void rejectsARevokedKey() throws Exception { + String knownHosts = "@revoked * " + PublicKeyEntry.toString(otherKey) + "\n" + entry("db.example.com", otherKey); + assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts)); + assertFalse(verify(false, "db.example.com", 22, otherKey, knownHosts)); + assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts + entry("db.example.com", serverKey))); + } + + @Test + void ignoresCertificateAuthorityEntries() throws Exception { + String knownHosts = "@cert-authority db.example.com " + PublicKeyEntry.toString(serverKey) + "\n"; + assertFalse(verify(true, "db.example.com", 22, serverKey, knownHosts)); + } + + @Test + void rejectsAnotherKeyForAListedHostWhateverFailOnUnknownHost() throws Exception { + String knownHosts = entry("10.0.0.12", otherKey) + entry("10.0.0.1", serverKey); + assertFalse(verify(false, "10.0.0.1", 22, otherKey, knownHosts)); + assertFalse(verify(false, "db.example.com", 22, otherKey, entry("db.example.com", serverKey))); + } + + @Test + void appliesFailOnUnknownHostToHostsWithoutEntries() throws Exception { + String knownHosts = entry("www.example.com", serverKey); + assertFalse(verify(true, "db.example.com", 22, serverKey, knownHosts)); + assertTrue(verify(false, "db.example.com", 22, serverKey, knownHosts)); + assertFalse(verify(true, "db.example.com", 22, serverKey, "")); + assertTrue(verify(false, "db.example.com", 22, serverKey, "")); + } + + @Test + void looksUpAnAddressAsConfigured() throws Exception { + // an endpoint configured with an address is looked up by that address, not by the name of a reverse lookup + String knownHosts = entry("localhost", otherKey) + entry("127.0.0.1", serverKey); + assertFalse(verify(true, new InetSocketAddress("127.0.0.1", 22), otherKey, knownHosts)); + assertTrue(verify(true, new InetSocketAddress("127.0.0.1", 22), serverKey, knownHosts)); + } + + @Test + void verifiesAllSupportedKeyTypes() throws Exception { + ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(context, "classpath:known_hosts_types", true); + try (InputStream knownHosts = getClass().getResourceAsStream("/known_hosts_types")) { + assertNotNull(knownHosts); + for (String line : IoUtils.readAllLines(knownHosts)) { + String[] fields = line.split(" "); + PublicKey key = PublicKeyEntry.parsePublicKeyEntry(fields[1] + " " + fields[2]) + .resolvePublicKey(null, Collections.emptyMap(), PublicKeyEntryResolver.FAILING); + assertTrue(verifier.verifyServerKey(null, new InetSocketAddress(fields[0], 22), key), fields[1]); + assertFalse(verifier.verifyServerKey(null, new InetSocketAddress(fields[0], 22), serverKey), fields[1]); } - }); + } + } + + private boolean verify(boolean failOnUnknownHost, String host, int port, PublicKey presented, String knownHosts) + throws Exception { + // the SSH client hands the verifier the configured host name together with its resolved address + InetAddress address = InetAddress.getByAddress(host, new byte[] { 10, 0, 0, 1 }); + return verify(failOnUnknownHost, new InetSocketAddress(address, port), presented, knownHosts); + } + + private boolean verify( + boolean failOnUnknownHost, InetSocketAddress server, PublicKey presented, String knownHosts) + throws Exception { + Path file = Files.createTempFile(dir, "known_hosts", ""); + Files.writeString(file, knownHosts, StandardCharsets.UTF_8); + ResourceBasedSSHKeyVerifier verifier + = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost); + return verifier.verifyServerKey(null, server, presented); + } + + private static String entry(String hosts, PublicKey key) { + return hosts + " " + PublicKeyEntry.toString(key) + "\n"; + } + + private static String hashed(String host) throws Exception { + byte[] salt = new byte[20]; + Arrays.fill(salt, (byte) 7); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(salt, "HmacSHA1")); + byte[] hash = mac.doFinal(host.getBytes(StandardCharsets.UTF_8)); + return "|1|" + Base64.getEncoder().encodeToString(salt) + "|" + Base64.getEncoder().encodeToString(hash); } } diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java index 243610be3cad1..2fc472611aa7e 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java @@ -22,6 +22,14 @@ public class SshComponentKnownHostEdDSATest extends SshComponentTestSupport { + private String knownHosts; + + @Override + public void doPreSetup() throws Exception { + super.doPreSetup(); + knownHosts = writeKnownHosts("known_hosts_eddsa", "[localhost]:" + port + " " + serverPublicKey()); + } + @Test public void testProducerWithEdDSAKeyType() throws Exception { final String msg = "test"; @@ -50,12 +58,13 @@ public void configure() { onException(Exception.class).handled(true).to("mock:error"); from("ssh://smx:smx@localhost:" + port - + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=classpath:known_hosts_eddsa&failOnUnknownHost=true") + + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=" + knownHosts + + "&failOnUnknownHost=true") .to("mock:result"); from("direct:ssh") .to("ssh://smx:smx@localhost:" + port - + "?timeout=3000&knownHostsResource=classpath:known_hosts_eddsa&failOnUnknownHost=true") + + "?timeout=3000&knownHostsResource=" + knownHosts + "&failOnUnknownHost=true") .to("mock:password"); } }; diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java index 72915c883e7b8..73aef6e19fe1f 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java @@ -16,12 +16,34 @@ */ package org.apache.camel.component.ssh; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; + import org.apache.camel.builder.RouteBuilder; import org.apache.camel.component.mock.MockEndpoint; +import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.junit.jupiter.api.Test; public class SshComponentKnownHostTest extends SshComponentTestSupport { + private String knownHostsValid; + private String knownHostsOtherHost; + private String knownHostsSeveralKeys; + + @Override + public void doPreSetup() throws Exception { + super.doPreSetup(); + String serverKey = serverPublicKey(); + String serverEntry = "[localhost]:" + port + " " + serverKey; + knownHostsValid = writeKnownHosts("known_hosts_valid", serverEntry); + knownHostsOtherHost = writeKnownHosts("known_hosts_other_host", "old-localhost " + serverKey); + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + String otherKey = PublicKeyEntry.toString(generator.generateKeyPair().getPublic()); + knownHostsSeveralKeys + = writeKnownHosts("known_hosts_several_keys", "[localhost]:" + port + " " + otherKey, serverEntry); + } + @Test public void testProducerWithValidFile() throws Exception { final String msg = "test"; @@ -64,6 +86,30 @@ public void testProducerWithInvalidFileWarnOnly() throws Exception { MockEndpoint.assertIsSatisfied(context); } + @Test + void testProducerWithKeyListedForAnotherHost() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:password"); + mock.expectedMessageCount(0); + getMockEndpoint("mock:error").expectedMessageCount(1); + + template.sendBody("direct:sshOtherHost", "test"); + + MockEndpoint.assertIsSatisfied(context); + } + + @Test + void testProducerWithSeveralKeysForTheServer() throws Exception { + final String msg = "test"; + + MockEndpoint mock = getMockEndpoint("mock:password"); + mock.expectedMessageCount(1); + mock.expectedBodiesReceived(msg); + + template.sendBody("direct:sshSeveralKeys", msg); + + MockEndpoint.assertIsSatisfied(context); + } + @Test public void testPollingConsumerWithValidKnownHostFile() throws Exception { MockEndpoint mock = getMockEndpoint("mock:result"); @@ -99,7 +145,8 @@ public void configure() { onException(Exception.class).handled(true).to("mock:error"); from("ssh://smx:smx@localhost:" + port - + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=classpath:known_hosts_valid&failOnUnknownHost=true") + + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=" + knownHostsValid + + "&failOnUnknownHost=true") .to("mock:result"); from("ssh://smx:smx@localhost:" + port @@ -112,7 +159,17 @@ public void configure() { from("direct:ssh") .to("ssh://smx:smx@localhost:" + port - + "?timeout=3000&knownHostsResource=classpath:known_hosts_valid&failOnUnknownHost=true") + + "?timeout=3000&knownHostsResource=" + knownHostsValid + "&failOnUnknownHost=true") + .to("mock:password"); + + from("direct:sshOtherHost") + .to("ssh://smx:smx@localhost:" + port + + "?timeout=3000&knownHostsResource=" + knownHostsOtherHost + "&failOnUnknownHost=true") + .to("mock:password"); + + from("direct:sshSeveralKeys") + .to("ssh://smx:smx@localhost:" + port + + "?timeout=3000&knownHostsResource=" + knownHostsSeveralKeys + "&failOnUnknownHost=true") .to("mock:password"); from("direct:sshInvalid").to("ssh://smx:smx@localhost:" + port diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java index e0260028ae437..d360732d5637d 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java @@ -17,9 +17,14 @@ package org.apache.camel.component.ssh; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyPair; import org.apache.camel.test.junit6.CamelTestSupport; +import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.keyprovider.FileKeyPairProvider; import org.apache.sshd.server.SshServer; @@ -43,6 +48,25 @@ protected String getHostKey() { return "src/test/resources/hostkey.pem"; } + /** + * The public key of the test server, in the form used by a known_hosts entry. + */ + protected String serverPublicKey() throws IOException, GeneralSecurityException { + KeyPair keyPair = sshd.getKeyPairProvider().loadKeys(null).iterator().next(); + return PublicKeyEntry.toString(keyPair.getPublic()); + } + + /** + * Writes a known_hosts file for this test and returns it as a resource. The test server listens on a random port, + * so its entries must use the [localhost]:port form. + */ + protected String writeKnownHosts(String name, String... entries) throws IOException { + Path file = Paths.get("target", "known_hosts", getClass().getSimpleName(), name); + Files.createDirectories(file.getParent()); + Files.writeString(file, String.join("\n", entries) + "\n"); + return "file:" + file; + } + @Override public void doPostTearDown() throws IOException, InterruptedException { if (sshd != null) { diff --git a/components/camel-ssh/src/test/resources/known_hosts_eddsa b/components/camel-ssh/src/test/resources/known_hosts_eddsa deleted file mode 100644 index 8ccd01181c5c3..0000000000000 --- a/components/camel-ssh/src/test/resources/known_hosts_eddsa +++ /dev/null @@ -1 +0,0 @@ -127.0.0.1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGY16PbGw2BjMtxaUUWBYK9TKVeavaOXG2FvlAEe80x8 diff --git a/components/camel-ssh/src/test/resources/known_hosts_valid b/components/camel-ssh/src/test/resources/known_hosts_valid deleted file mode 100644 index e7ae1daf75e11..0000000000000 --- a/components/camel-ssh/src/test/resources/known_hosts_valid +++ /dev/null @@ -1 +0,0 @@ -127.0.0.1 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDdfIWeSV4o68dRrKSzFd/Bk51E65UTmmSrmW0O1ohtzi6HzsDPjXgCtlTt3FqTcfFfI92IlTr4JWqC9UK1QT1ZTeng0MkPQmv68hDANHbt5CpETZHjW5q4OOgWhVvj5IyOC2NZHtKlJBkdsMAa15ouOOJLzBvAvbqOR/yUROsEiQ== \ No newline at end of file diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index db3e7ab13a974..191c259f8b350 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -37,6 +37,27 @@ A saga option named like a parameter of the participant callback URLs, in any ca `Camel-Saga-Complete`, `Camel-Saga-Signature` or `Camel-Saga-Lra`), now fails the creation of its route, with or without a callback secret. The value of such an option could not be read back from the callback before. +=== camel-ssh - known_hosts entries are matched as in OpenSSH + +When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH +matches them, against the host name or address and the port configured on the endpoint: + +* A host pattern must match the configured host as a whole. An entry for another host whose name or +address contains the configured one is no longer used. +* An entry without a port only applies to port 22. A server on another port needs an entry of the +form `[host]:port`. +* The configured host is used as is. An endpoint configured with a host name no longer uses entries +listed only under its address, and an endpoint configured with an address no longer uses entries +listed under the name that a reverse lookup of the address returns. +* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated +and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are +ignored. + +As before, a server whose host has entries is rejected when it presents a key that none of them lists, +whatever the value of `failOnUnknownHost`, which only applies to a host without any entry. +Check that the `known_hosts` file has an entry for the host name or address, and the port, that each +endpoint is configured with. + === camel-netty-http - the roles of a security constraint are matched by role name The roles of a `SecurityConstraintMapping` inclusion are a comma-separated list of role names, and an authenticated diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index de32f08a20b4d..9fa460a53c112 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -140,6 +140,27 @@ sent to the partition with the same id in the target Event Hub. To keep doing so Event Hubs that have the same partitions, remove the `CamelAzureEventHubsReceivedPartitionId` exchange property before the producer. +=== camel-ssh - known_hosts entries are matched as in OpenSSH + +When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH +matches them, against the host name or address and the port configured on the endpoint: + +* A host pattern must match the configured host as a whole. An entry for another host whose name or +address contains the configured one is no longer used. +* An entry without a port only applies to port 22. A server on another port needs an entry of the +form `[host]:port`. +* The configured host is used as is. An endpoint configured with a host name no longer uses entries +listed only under its address, and an endpoint configured with an address no longer uses entries +listed under the name that a reverse lookup of the address returns. +* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated +and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are +ignored. + +As before, a server whose host has entries is rejected when it presents a key that none of them lists, +whatever the value of `failOnUnknownHost`, which only applies to a host without any entry. +Check that the `known_hosts` file has an entry for the host name or address, and the port, that each +endpoint is configured with. + === camel-xslt and camel-xslt-saxon - implicit DOM sources When a non-`Source` input is implicitly converted to a `DOMSource` pointing at its owner document's diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 35c93c0ad8e81..3cdbf51dbc588 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -97,6 +97,27 @@ now read as a local date-time without a zone (`getDate()` returns a `LocalDateTi definition of an earlier message, if there was one. A calendar that defines a `TZID` an earlier message has defined differently is now read with its own definition. +=== camel-ssh - known_hosts entries are matched as in OpenSSH + +When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH +matches them, against the host name or address and the port configured on the endpoint: + +* A host pattern must match the configured host as a whole. An entry for another host whose name or +address contains the configured one is no longer used. +* An entry without a port only applies to port 22. A server on another port needs an entry of the +form `[host]:port`. +* The configured host is used as is. An endpoint configured with a host name no longer uses entries +listed only under its address, and an endpoint configured with an address no longer uses entries +listed under the name that a reverse lookup of the address returns. +* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated +and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are +ignored. + +As before, a server whose host has entries is rejected when it presents a key that none of them lists, +whatever the value of `failOnUnknownHost`, which only applies to a host without any entry. +Check that the `known_hosts` file has an entry for the host name or address, and the port, that each +endpoint is configured with. + === camel-lra - only an LRA hosted by the configured coordinator is joined The LRA saga service now joins, completes or compensates an LRA only when its URL is under the configured LRA diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java index 19243cffd3263..54ac60dd7413d 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java @@ -445,9 +445,10 @@ default SshComponentBuilder ciphers(java.lang.String ciphers) { /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option is a: <code>boolean</code> type. * @@ -516,8 +517,10 @@ default SshComponentBuilder keyType(java.lang.String keyType) { /** * Sets the resource path for a known_hosts file used to verify the SSH - * server host key. When not set, the client does not verify the server - * host key against a known_hosts file. + * server host key. The file uses the OpenSSH known_hosts format, and + * its entries are matched against the host and port configured on the + * endpoint. When not set, the client does not verify the server host + * key against a known_hosts file. * * The option is a: <code>java.lang.String</code> type. * diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java index e83926e90b7f5..fbecff92f4fc6 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java @@ -628,9 +628,10 @@ default SshEndpointConsumerBuilder ciphers(String ciphers) { return this; } /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option is a: boolean type. * @@ -645,9 +646,10 @@ default SshEndpointConsumerBuilder failOnUnknownHost(boolean failOnUnknownHost) return this; } /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option will be converted to a boolean type. * @@ -728,8 +730,10 @@ default SshEndpointConsumerBuilder keyType(String keyType) { } /** * Sets the resource path for a known_hosts file used to verify the SSH - * server host key. When not set, the client does not verify the server - * host key against a known_hosts file. + * server host key. The file uses the OpenSSH known_hosts format, and + * its entries are matched against the host and port configured on the + * endpoint. When not set, the client does not verify the server host + * key against a known_hosts file. * * The option is a: java.lang.String type. * @@ -1344,9 +1348,10 @@ default SshEndpointProducerBuilder ciphers(String ciphers) { return this; } /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option is a: boolean type. * @@ -1361,9 +1366,10 @@ default SshEndpointProducerBuilder failOnUnknownHost(boolean failOnUnknownHost) return this; } /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option will be converted to a boolean type. * @@ -1444,8 +1450,10 @@ default SshEndpointProducerBuilder keyType(String keyType) { } /** * Sets the resource path for a known_hosts file used to verify the SSH - * server host key. When not set, the client does not verify the server - * host key against a known_hosts file. + * server host key. The file uses the OpenSSH known_hosts format, and + * its entries are matched against the host and port configured on the + * endpoint. When not set, the client does not verify the server host + * key against a known_hosts file. * * The option is a: java.lang.String type. * @@ -1953,9 +1961,10 @@ default SshEndpointBuilder ciphers(String ciphers) { return this; } /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option is a: boolean type. * @@ -1970,9 +1979,10 @@ default SshEndpointBuilder failOnUnknownHost(boolean failOnUnknownHost) { return this; } /** - * Specifies whether a connection to an unknown SSH server host key - * should fail. When false, host keys that are not present in the - * known_hosts resource are accepted. + * Specifies whether a connection to a server whose host has no entry in + * the known_hosts resource should fail. When false, such a server is + * accepted. A server whose host has entries is always rejected when it + * presents a key that none of them lists. * * The option will be converted to a boolean type. * @@ -2053,8 +2063,10 @@ default SshEndpointBuilder keyType(String keyType) { } /** * Sets the resource path for a known_hosts file used to verify the SSH - * server host key. When not set, the client does not verify the server - * host key against a known_hosts file. + * server host key. The file uses the OpenSSH known_hosts format, and + * its entries are matched against the host and port configured on the + * endpoint. When not set, the client does not verify the server host + * key against a known_hosts file. * * The option is a: java.lang.String type. * From 370054641e57dbe8e2cf20ed763d8140a9c3b273 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 14 Oct 2026 13:03:25 +0200 Subject: [PATCH 2/7] CAMEL-25644: camel-ssh - match known_hosts patterns as OpenSSH does Read the known_hosts lines in the verifier instead of with MINA SSHD's KnownHostEntry, whose pattern parser drops a line with a negated port-qualified pattern such as ![host]:2222 and only applies a pattern without a port, * included, to port 22. Each host pattern, and each hashed entry, is now matched against the name OpenSSH looks the server up by: host for port 22 and [host]:port for another port. A resource or a line that cannot be read, including one with a marker other than @revoked or @cert-authority, now makes every connection fail, so a parse failure can no longer turn a listed host into an unknown one. Keys are still resolved and compared with MINA SSHD. Say in the docs, the option descriptions and the upgrade notes that only entries without a marker list a host, as in OpenSSH. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../apache/camel/catalog/components/ssh.json | 8 +- .../camel/catalog/docs/ssh-component.adoc | 22 +- .../org/apache/camel/component/ssh/ssh.json | 8 +- .../src/main/docs/ssh-component.adoc | 22 +- .../ssh/ResourceBasedSSHKeyVerifier.java | 245 ++++++++++++++---- .../camel/component/ssh/SshConfiguration.java | 17 +- .../ssh/ResourceBasedSSHKeyVerifierTest.java | 70 +++++ .../pages/camel-4x-upgrade-guide-4_18.adoc | 22 +- .../pages/camel-4x-upgrade-guide-4_22.adoc | 22 +- .../pages/camel-4x-upgrade-guide-4_24.adoc | 22 +- .../dsl/SshComponentBuilderFactory.java | 15 +- .../dsl/SshEndpointBuilderFactory.java | 75 +++--- 12 files changed, 398 insertions(+), 150 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json index a846165593e43..5a4c876245204 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json @@ -47,11 +47,11 @@ "certResource": { "index": 19, "kind": "property", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 20, "kind": "property", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 21, "kind": "property", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, + "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." }, "kex": { "index": 23, "kind": "property", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 24, "kind": "property", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 25, "kind": "property", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 27, "kind": "property", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 28, "kind": "property", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 29, "kind": "property", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, @@ -102,11 +102,11 @@ "certResource": { "index": 35, "kind": "parameter", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 36, "kind": "parameter", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 37, "kind": "parameter", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, + "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." }, "kex": { "index": 39, "kind": "parameter", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 40, "kind": "parameter", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 41, "kind": "parameter", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 43, "kind": "parameter", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 44, "kind": "parameter", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 45, "kind": "parameter", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc index ec1f5ba0c3887..d4c86c59f38f5 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc @@ -172,17 +172,21 @@ When it is `false` (the default), such a server is accepted and a warning is log The entries are matched as OpenSSH matches them: -* The host name or address configured on the endpoint is compared with each comma-separated host -pattern of an entry, ignoring case. Wildcards (`*` and `?`), negated patterns (`!`) and hashed -entries are supported. The configured value is used as is: an endpoint configured with an address -only uses the entries for that address. -* An entry without a port applies to port 22 only. For another port, use the `[host]:port` form, -for example `[db.example.com]:2222`. -* The server is accepted when one of the entries for its host lists the key it presents. A host can be -listed with several keys, one per line. +* Each host pattern of an entry is compared, ignoring case, with the host name or address configured +on the endpoint, written `host` for port 22 and `[host]:port` for another port. `*` and `?` are +wildcards, a pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. +So `db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every +host and port. The configured value is used as is: an endpoint configured with an address only uses +the entries for that address. +* The server is accepted when an entry of its host lists the key it presents. A host can be listed +with several keys, one per line. * The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when -its host has entries but none of them lists that key, whatever the value of `failOnUnknownHost`. +its host has entries without a marker but none of them lists that key, whatever the value of +`failOnUnknownHost`. That option only decides for a host without such an entry: `@revoked` and +`@cert-authority` entries do not list a host. * `@cert-authority` entries (OpenSSH host certificates) are not supported and are ignored. +* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example +an unknown `@` marker, a missing key, or a key that is not valid base64), makes every connection fail. The following route verifies the host key of a server listening on port 2222 against a `known_hosts` file that contains the line `[db.example.com]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...`. diff --git a/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json b/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json index a846165593e43..5a4c876245204 100644 --- a/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json +++ b/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json @@ -47,11 +47,11 @@ "certResource": { "index": 19, "kind": "property", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 20, "kind": "property", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 21, "kind": "property", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, + "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." }, "kex": { "index": 23, "kind": "property", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 24, "kind": "property", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 25, "kind": "property", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 27, "kind": "property", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 28, "kind": "property", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 29, "kind": "property", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, @@ -102,11 +102,11 @@ "certResource": { "index": 35, "kind": "parameter", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." }, "certResourcePassword": { "index": 36, "kind": "parameter", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." }, "ciphers": { "index": 37, "kind": "parameter", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." }, - "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries is always rejected when it presents a key that none of them lists." }, + "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." }, "kex": { "index": 39, "kind": "parameter", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." }, "keyPairProvider": { "index": 40, "kind": "parameter", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." }, "keyType": { "index": 41, "kind": "parameter", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." }, - "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. When not set, the client does not verify the server host key against a known_hosts file." }, + "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." }, "macs": { "index": 43, "kind": "parameter", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." }, "password": { "index": 44, "kind": "parameter", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." }, "signatures": { "index": 45, "kind": "parameter", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." }, diff --git a/components/camel-ssh/src/main/docs/ssh-component.adoc b/components/camel-ssh/src/main/docs/ssh-component.adoc index ec1f5ba0c3887..d4c86c59f38f5 100644 --- a/components/camel-ssh/src/main/docs/ssh-component.adoc +++ b/components/camel-ssh/src/main/docs/ssh-component.adoc @@ -172,17 +172,21 @@ When it is `false` (the default), such a server is accepted and a warning is log The entries are matched as OpenSSH matches them: -* The host name or address configured on the endpoint is compared with each comma-separated host -pattern of an entry, ignoring case. Wildcards (`*` and `?`), negated patterns (`!`) and hashed -entries are supported. The configured value is used as is: an endpoint configured with an address -only uses the entries for that address. -* An entry without a port applies to port 22 only. For another port, use the `[host]:port` form, -for example `[db.example.com]:2222`. -* The server is accepted when one of the entries for its host lists the key it presents. A host can be -listed with several keys, one per line. +* Each host pattern of an entry is compared, ignoring case, with the host name or address configured +on the endpoint, written `host` for port 22 and `[host]:port` for another port. `*` and `?` are +wildcards, a pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. +So `db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every +host and port. The configured value is used as is: an endpoint configured with an address only uses +the entries for that address. +* The server is accepted when an entry of its host lists the key it presents. A host can be listed +with several keys, one per line. * The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when -its host has entries but none of them lists that key, whatever the value of `failOnUnknownHost`. +its host has entries without a marker but none of them lists that key, whatever the value of +`failOnUnknownHost`. That option only decides for a host without such an entry: `@revoked` and +`@cert-authority` entries do not list a host. * `@cert-authority` entries (OpenSSH host certificates) are not supported and are ignored. +* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example +an unknown `@` marker, a missing key, or a key that is not valid base64), makes every connection fail. The following route verifies the host key of a server listening on port 2222 against a `known_hosts` file that contains the line `[db.example.com]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...`. diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java index eb310ca0bf4c5..526ce1fe95c52 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java @@ -20,36 +20,49 @@ import java.io.InputStream; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; +import java.security.MessageDigest; import java.security.PublicKey; +import java.util.Base64; import java.util.Collections; -import java.util.List; +import java.util.Locale; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.apache.camel.CamelContext; import org.apache.camel.support.ResourceHelper; -import org.apache.camel.util.IOHelper; -import org.apache.sshd.client.config.hosts.KnownHostEntry; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.apache.sshd.common.session.SessionContext; import org.apache.sshd.common.util.net.SshdSocketAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * ServerKeyVerifier that takes a camel resource as input file to validate the server key against. - *

- * The resource uses the OpenSSH known_hosts format. The entries are matched against the host name (or address) and port - * the client connects to, as configured on the endpoint: host patterns are matched as a whole, wildcard, negated and - * hashed patterns are supported, and an entry without a port only applies to port 22. The server is accepted when one - * of the matching entries holds the key it presents, and rejected when that key is marked as {@code @revoked} or when - * the host has entries but none of them holds that key. Only a host without any matching entry is subject to - * {@code failOnUnknownHost}. Host certificates ({@code @cert-authority} entries) are not supported. + *

+ * The resource uses the OpenSSH known_hosts format, and its entries are matched as OpenSSH matches them. Each host + * pattern of an entry is compared, ignoring case, with the host name or address configured on the endpoint, written + * {@code host} for port 22 and {@code [host]:port} for another port: {@code *} and {@code ?} are wildcards, a pattern + * starting with {@code !} excludes the hosts it matches, and hashed entries are supported. The server is accepted when + * an entry of its host lists the key it presents. It is rejected when that key is listed in a matching {@code @revoked} + * entry, or when its host has entries without a marker but none of them lists that key. {@code failOnUnknownHost} only + * decides for a host without such an entry: {@code @revoked} and {@code @cert-authority} entries do not list a host, + * and host certificates are not supported. A resource that cannot be read, or that has a line that cannot be read, + * rejects every server. */ public class ResourceBasedSSHKeyVerifier implements ServerKeyVerifier { - private static final String MARKER_REVOKED = "revoked"; - private static final String MARKER_CERT_AUTHORITY = "cert-authority"; + private static final int DEFAULT_PORT = 22; + private static final String MARKER_REVOKED = "@revoked"; + private static final String MARKER_CERT_AUTHORITY = "@cert-authority"; + private static final String HASHED_HOST_PREFIX = "|1|"; + private static final String HASH_ALGORITHM = "HmacSHA1"; + private static final int HASH_SALT_LENGTH = 20; protected final Logger log = LoggerFactory.getLogger(getClass()); @@ -71,40 +84,46 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR @Override public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress remoteAddress, PublicKey serverKey) { log.debug("Trying to find known_hosts file {}", knownHostsResource); - List entries = Collections.emptyList(); - InputStream knownHostsInputStream = null; - try { - knownHostsInputStream = ResourceHelper.resolveMandatoryResourceAsInputStream(camelContext, - knownHostsResource); - entries = KnownHostEntry.readKnownHostEntries(knownHostsInputStream, false); + String knownHosts; + try (InputStream knownHostsInputStream + = ResourceHelper.resolveMandatoryResourceAsInputStream(camelContext, knownHostsResource)) { + knownHosts = new String(knownHostsInputStream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException ioException) { - log.debug(String.format("Could not find known_hosts file %s", knownHostsResource), ioException); - } finally { - IOHelper.close(knownHostsInputStream); + log.warn("Could not read the known_hosts file {}, connection will fail", knownHostsResource, ioException); + return false; } - SshdSocketAddress server = toServerAddress(remoteAddress); + String server = toKnownHostsName(remoteAddress); boolean hostListed = false; boolean keyListed = false; - if (server != null) { - log.debug("Trying to match PublicKey of {} against provided known_hosts file", server); - for (KnownHostEntry entry : entries) { - if (!entry.isHostMatch(server.getHostName(), server.getPort()) - || MARKER_CERT_AUTHORITY.equals(entry.getMarker())) { - continue; + int lineNumber = 0; + for (String line : knownHosts.split("\\R")) { + lineNumber++; + String text = line.trim(); + if (text.isEmpty() || text.charAt(0) == '#') { + continue; + } + // a line that cannot be read could list the server, so it cannot be skipped + KnownHostsEntry entry = KnownHostsEntry.parse(text, sshClientSession); + if (entry == null) { + log.warn("Line {} of the known_hosts file {} cannot be read, connection will fail", lineNumber, + knownHostsResource); + return false; + } + if (server == null || MARKER_CERT_AUTHORITY.equals(entry.marker) || !entry.matches(server)) { + continue; + } + boolean sameKey = isSameKey(entry, serverKey); + if (MARKER_REVOKED.equals(entry.marker)) { + if (sameKey) { + log.warn("The key presented by {} is marked as revoked in the known_hosts file {}", server, + knownHostsResource); + return false; } - boolean sameKey = isSameKey(sshClientSession, entry, serverKey); - if (MARKER_REVOKED.equals(entry.getMarker())) { - if (sameKey) { - log.warn("The key presented by {} is marked as revoked in the known_hosts file {}", server, - knownHostsResource); - return false; - } - } else { - hostListed = true; - if (sameKey) { - keyListed = true; - } + } else { + hostListed = true; + if (sameKey) { + keyListed = true; } } } @@ -128,31 +147,143 @@ public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress rem } } - private boolean isSameKey(ClientSession sshClientSession, KnownHostEntry entry, PublicKey serverKey) { - String keyType = entry.getKeyEntry().getKeyType(); - try { - PublicKey key = entry.getKeyEntry().resolvePublicKey(sshClientSession, PublicKeyEntryResolver.IGNORING); - if (key == null) { - log.warn("Unsupported key type {} in the known_hosts file {}", keyType, knownHostsResource); + private boolean isSameKey(KnownHostsEntry entry, PublicKey serverKey) { + if (entry.key == null) { + log.warn("Unsupported key type {} in the known_hosts file {}", entry.keyType, knownHostsResource); + return false; + } + return KeyUtils.compareKeys(entry.key, serverKey); + } + + /* + * The name OpenSSH looks the server up by: the host name or address the client connected to, as configured on the + * endpoint and never resolved, written host for port 22 and [host]:port for another port. + */ + private static String toKnownHostsName(SocketAddress remoteAddress) { + if (!(remoteAddress instanceof InetSocketAddress) && !(remoteAddress instanceof SshdSocketAddress)) { + return null; + } + SshdSocketAddress address = SshdSocketAddress.toSshdSocketAddress(remoteAddress); + String host = address.getHostName().toLowerCase(Locale.ROOT); + return address.getPort() == DEFAULT_PORT ? host : "[" + host + "]:" + address.getPort(); + } + + /* + * Matches a pattern where * stands for any sequence of characters and ? for any single character. + */ + static boolean matchesPattern(String pattern, String name) { + int p = 0; + int n = 0; + int star = -1; + int resume = 0; + while (n < name.length()) { + if (p < pattern.length() && (pattern.charAt(p) == '?' || pattern.charAt(p) == name.charAt(n))) { + p++; + n++; + } else if (p < pattern.length() && pattern.charAt(p) == '*') { + star = p++; + resume = n; + } else if (star >= 0) { + p = star + 1; + n = ++resume; + } else { return false; } - return KeyUtils.compareKeys(key, serverKey); - } catch (IOException | GeneralSecurityException | RuntimeException e) { - // a key that cannot be decoded cannot be the key the server presented - log.warn("Could not load a {} key from the known_hosts file {}", keyType, knownHostsResource, e); - return false; } + while (p < pattern.length() && pattern.charAt(p) == '*') { + p++; + } + return p == pattern.length(); } /* - * The host name or address and the port the client connected to. The host is used exactly as given, never - * resolved, so an endpoint configured with an address is not looked up by the name a reverse lookup returns. + * A line of the known_hosts file: an optional marker, the host patterns or a hashed host, and the key. Anything + * after the key is a comment. */ - private static SshdSocketAddress toServerAddress(SocketAddress remoteAddress) { - if (remoteAddress instanceof InetSocketAddress || remoteAddress instanceof SshdSocketAddress) { - return SshdSocketAddress.toSshdSocketAddress(remoteAddress); + private static final class KnownHostsEntry { + private final String marker; + private final String hosts; + private final byte[] salt; + private final byte[] hash; + private final String keyType; + private final PublicKey key; + + private KnownHostsEntry(String marker, String hosts, byte[] salt, byte[] hash, String keyType, PublicKey key) { + this.marker = marker; + this.hosts = hosts; + this.salt = salt; + this.hash = hash; + this.keyType = keyType; + this.key = key; + } + + /* + * Returns null when the line cannot be read. A key of a type that is not supported is kept as a null key. + */ + static KnownHostsEntry parse(String line, SessionContext session) { + String[] fields = line.split("\\s+"); + int index = 0; + String marker = null; + if (fields[0].startsWith("@")) { + marker = fields[0]; + if (!MARKER_REVOKED.equals(marker) && !MARKER_CERT_AUTHORITY.equals(marker)) { + return null; + } + index = 1; + } + if (fields.length < index + 3) { + return null; + } + String hosts = fields[index]; + try { + byte[] salt = null; + byte[] hash = null; + if (hosts.startsWith(HASHED_HOST_PREFIX)) { + String[] parts = hosts.substring(HASHED_HOST_PREFIX.length()).split("\\|", -1); + if (parts.length != 2) { + return null; + } + salt = Base64.getDecoder().decode(parts[0]); + hash = Base64.getDecoder().decode(parts[1]); + if (salt.length != HASH_SALT_LENGTH) { + return null; + } + } + PublicKeyEntry keyEntry = PublicKeyEntry.parsePublicKeyEntry(fields[index + 1] + " " + fields[index + 2]); + PublicKey key = keyEntry.resolvePublicKey(session, Collections.emptyMap(), PublicKeyEntryResolver.IGNORING); + return new KnownHostsEntry(marker, hosts, salt, hash, keyEntry.getKeyType(), key); + } catch (IOException | GeneralSecurityException | RuntimeException e) { + return null; + } + } + + boolean matches(String name) { + if (hash != null) { + return MessageDigest.isEqual(hash, hashOf(name)); + } + boolean matched = false; + for (String pattern : hosts.split(",")) { + boolean negated = pattern.startsWith("!"); + String hostPattern = (negated ? pattern.substring(1) : pattern).toLowerCase(Locale.ROOT); + if (!hostPattern.isEmpty() && matchesPattern(hostPattern, name)) { + if (negated) { + return false; + } + matched = true; + } + } + return matched; + } + + private byte[] hashOf(String name) { + try { + Mac mac = Mac.getInstance(HASH_ALGORITHM); + mac.init(new SecretKeySpec(salt, HASH_ALGORITHM)); + return mac.doFinal(name.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Cannot compute the hash of a known_hosts host", e); + } } - return null; } } diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java index 76e9da3b80f97..717c996213454 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java @@ -57,13 +57,15 @@ public class SshConfiguration implements Cloneable { @UriParam(label = "security", security = "insecure:ssl", description = "Sets the resource path for a known_hosts file used to verify the SSH server host key." + " The file uses the OpenSSH known_hosts format, and its entries are matched against the" - + " host and port configured on the endpoint." + + " host and port configured on the endpoint. A resource that cannot be read, or that has a" + + " line that cannot be read, makes every connection fail." + " When not set, the client does not verify the server host key against a known_hosts file.") private String knownHostsResource; @UriParam(label = "security", defaultValue = "false", security = "insecure:ssl", insecureValue = "false", - description = "Specifies whether a connection to a server whose host has no entry in the known_hosts" - + " resource should fail. When false, such a server is accepted. A server whose host has" - + " entries is always rejected when it presents a key that none of them lists.") + description = "Specifies whether a connection to a server whose host has no entry without a marker in" + + " the known_hosts resource should fail. When false, such a server is accepted. A server" + + " whose host has entries without a marker is always rejected when it presents a key that" + + " none of them lists, and a key listed in a matching @revoked entry is always rejected.") private boolean failOnUnknownHost; @UriParam(label = "advanced", defaultValue = Channel.CHANNEL_EXEC) private String channelType = Channel.CHANNEL_EXEC; @@ -304,9 +306,10 @@ public boolean isFailOnUnknownHost() { } /** - * Specifies whether a connection to a server whose host has no entry in the known_hosts resource should fail or - * not. This value is only checked when the property knownHostsResource is set. A server whose host has entries is - * always rejected when it presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource + * should fail or not. This value is only checked when the property knownHostsResource is set. A server whose host + * has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed + * in a matching @revoked entry is always rejected. * * @param failOnUnknownHost boolean flag, whether a connection to an unknown host should fail */ diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java index bde1e4794d5f5..06798db788f75 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java @@ -100,6 +100,24 @@ void matchesThePort() throws Exception { assertFalse(verify(true, "db.example.com", 2222, serverKey, entry("db.example.com", serverKey))); } + @Test + void matchesPatternsAgainstTheHostAndPortAsOpenSshWritesThem() throws Exception { + assertTrue(verify(true, "db.example.com", 2222, serverKey, entry("*", serverKey))); + assertTrue(verify(true, "db.example.com", 2222, serverKey, entry("[*.example.com]:2222", serverKey))); + assertFalse(verify(true, "db.example.com", 2222, serverKey, entry("*.example.com", serverKey))); + assertTrue(verify(true, "db.example.com", 2222, serverKey, entry("[db.example.com]:22?2", serverKey))); + } + + @Test + void matchesNegatedPortQualifiedPatterns() throws Exception { + String knownHosts = entry("![excluded.example.com]:2222,[db.example.com]:2222", serverKey); + for (boolean failOnUnknownHost : new boolean[] { true, false }) { + assertTrue(verify(failOnUnknownHost, "db.example.com", 2222, serverKey, knownHosts)); + assertFalse(verify(failOnUnknownHost, "db.example.com", 2222, otherKey, knownHosts)); + } + assertFalse(verify(true, "excluded.example.com", 2222, serverKey, knownHosts)); + } + @Test void matchesWildcardNegatedAndUpperCasePatterns() throws Exception { assertTrue(verify(true, "db.example.com", 22, serverKey, entry("*.example.com", serverKey))); @@ -115,6 +133,8 @@ void matchesHashedEntries() throws Exception { assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts)); assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts)); assertFalse(verify(true, "www.example.com", 22, serverKey, knownHosts)); + assertFalse(verify(true, "db.example.com", 2222, serverKey, knownHosts)); + assertTrue(verify(true, "db.example.com", 2222, serverKey, entry(hashed("[db.example.com]:2222"), serverKey))); } @Test @@ -141,6 +161,56 @@ void rejectsARevokedKey() throws Exception { assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts + entry("db.example.com", serverKey))); } + @Test + void rejectsARevokedKeyOnEveryPortAndInEveryOrder() throws Exception { + String revoked = "@revoked * " + PublicKeyEntry.toString(serverKey) + "\n"; + for (int port : new int[] { 22, 2222 }) { + String listed = entry(port == 22 ? "db.example.com" : "[db.example.com]:" + port, serverKey); + for (boolean failOnUnknownHost : new boolean[] { true, false }) { + assertFalse(verify(failOnUnknownHost, "db.example.com", port, serverKey, revoked + listed), + () -> "revocation before the entry, port " + port + ", failOnUnknownHost " + failOnUnknownHost); + assertFalse(verify(failOnUnknownHost, "db.example.com", port, serverKey, listed + revoked), + () -> "revocation after the entry, port " + port + ", failOnUnknownHost " + failOnUnknownHost); + } + } + } + + @Test + void treatsAHostWithOnlyRevokedEntriesAsUnknown() throws Exception { + // as OpenSSH does: a revoked entry rejects its key but does not list the host + String knownHosts = "@revoked db.example.com " + PublicKeyEntry.toString(otherKey) + "\n"; + assertFalse(verify(true, "db.example.com", 22, serverKey, knownHosts)); + assertTrue(verify(false, "db.example.com", 22, serverKey, knownHosts)); + assertFalse(verify(false, "db.example.com", 22, otherKey, knownHosts)); + } + + @Test + void rejectsEveryServerWhenALineCannotBeRead() throws Exception { + String[] unreadableLines = { + "@unknown db.example.com " + PublicKeyEntry.toString(serverKey), + "@revoked", + "db.example.com", + "db.example.com ssh-ed25519 not*base64", + "|1|c2FsdA==|aGFzaA== " + PublicKeyEntry.toString(serverKey) }; + for (String line : unreadableLines) { + for (String knownHosts : new String[] { line + "\n", entry("db.example.com", serverKey) + line + "\n" }) { + for (boolean failOnUnknownHost : new boolean[] { true, false }) { + assertFalse(verify(failOnUnknownHost, "db.example.com", 22, serverKey, knownHosts), line); + assertFalse(verify(failOnUnknownHost, "www.example.com", 22, serverKey, knownHosts), line); + } + } + } + } + + @Test + void rejectsEveryServerWhenTheResourceCannotBeRead() { + for (boolean failOnUnknownHost : new boolean[] { true, false }) { + ResourceBasedSSHKeyVerifier verifier + = new ResourceBasedSSHKeyVerifier(context, "file:" + dir.resolve("missing"), failOnUnknownHost); + assertFalse(verifier.verifyServerKey(null, new InetSocketAddress("127.0.0.1", 22), serverKey)); + } + } + @Test void ignoresCertificateAuthorityEntries() throws Exception { String knownHosts = "@cert-authority db.example.com " + PublicKeyEntry.toString(serverKey) + "\n"; diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index 191c259f8b350..b72a315af502c 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -40,21 +40,27 @@ without a callback secret. The value of such an option could not be read back fr === camel-ssh - known_hosts entries are matched as in OpenSSH When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address and the port configured on the endpoint: +matches them, against the host name or address configured on the endpoint, written `host` for port 22 +and `[host]:port` for another port: -* A host pattern must match the configured host as a whole. An entry for another host whose name or -address contains the configured one is no longer used. -* An entry without a port only applies to port 22. A server on another port needs an entry of the -form `[host]:port`. +* A host pattern must match that name as a whole. An entry for another host whose name or address +contains the configured one is no longer used. +* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry +such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. * The configured host is used as is. An endpoint configured with a host name no longer uses entries listed only under its address, and an endpoint configured with an address no longer uses entries listed under the name that a reverse lookup of the address returns. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. - -As before, a server whose host has entries is rejected when it presents a key that none of them lists, -whatever the value of `failOnUnknownHost`, which only applies to a host without any entry. +* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an +unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail, +whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry, +and such a line was skipped. + +As before, a server whose host has entries without a marker is rejected when it presents a key that +none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without +such an entry: `@revoked` and `@cert-authority` entries do not list a host. Check that the `known_hosts` file has an entry for the host name or address, and the port, that each endpoint is configured with. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 9fa460a53c112..c33015a960c04 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -143,21 +143,27 @@ the producer. === camel-ssh - known_hosts entries are matched as in OpenSSH When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address and the port configured on the endpoint: +matches them, against the host name or address configured on the endpoint, written `host` for port 22 +and `[host]:port` for another port: -* A host pattern must match the configured host as a whole. An entry for another host whose name or -address contains the configured one is no longer used. -* An entry without a port only applies to port 22. A server on another port needs an entry of the -form `[host]:port`. +* A host pattern must match that name as a whole. An entry for another host whose name or address +contains the configured one is no longer used. +* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry +such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. * The configured host is used as is. An endpoint configured with a host name no longer uses entries listed only under its address, and an endpoint configured with an address no longer uses entries listed under the name that a reverse lookup of the address returns. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. - -As before, a server whose host has entries is rejected when it presents a key that none of them lists, -whatever the value of `failOnUnknownHost`, which only applies to a host without any entry. +* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an +unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail, +whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry, +and such a line was skipped. + +As before, a server whose host has entries without a marker is rejected when it presents a key that +none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without +such an entry: `@revoked` and `@cert-authority` entries do not list a host. Check that the `known_hosts` file has an entry for the host name or address, and the port, that each endpoint is configured with. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 3cdbf51dbc588..231e16f815ce7 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -100,21 +100,27 @@ differently is now read with its own definition. === camel-ssh - known_hosts entries are matched as in OpenSSH When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address and the port configured on the endpoint: +matches them, against the host name or address configured on the endpoint, written `host` for port 22 +and `[host]:port` for another port: -* A host pattern must match the configured host as a whole. An entry for another host whose name or -address contains the configured one is no longer used. -* An entry without a port only applies to port 22. A server on another port needs an entry of the -form `[host]:port`. +* A host pattern must match that name as a whole. An entry for another host whose name or address +contains the configured one is no longer used. +* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry +such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. * The configured host is used as is. An endpoint configured with a host name no longer uses entries listed only under its address, and an endpoint configured with an address no longer uses entries listed under the name that a reverse lookup of the address returns. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. - -As before, a server whose host has entries is rejected when it presents a key that none of them lists, -whatever the value of `failOnUnknownHost`, which only applies to a host without any entry. +* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an +unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail, +whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry, +and such a line was skipped. + +As before, a server whose host has entries without a marker is rejected when it presents a key that +none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without +such an entry: `@revoked` and `@cert-authority` entries do not list a host. Check that the `known_hosts` file has an entry for the host name or address, and the port, that each endpoint is configured with. diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java index 54ac60dd7413d..3665cc88fc718 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java @@ -445,10 +445,12 @@ default SshComponentBuilder ciphers(java.lang.String ciphers) { /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option is a: <code>boolean</code> type. * @@ -519,8 +521,9 @@ default SshComponentBuilder keyType(java.lang.String keyType) { * Sets the resource path for a known_hosts file used to verify the SSH * server host key. The file uses the OpenSSH known_hosts format, and * its entries are matched against the host and port configured on the - * endpoint. When not set, the client does not verify the server host - * key against a known_hosts file. + * endpoint. A resource that cannot be read, or that has a line that + * cannot be read, makes every connection fail. When not set, the client + * does not verify the server host key against a known_hosts file. * * The option is a: <code>java.lang.String</code> type. * diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java index fbecff92f4fc6..c12f8d86ca9df 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java @@ -628,10 +628,12 @@ default SshEndpointConsumerBuilder ciphers(String ciphers) { return this; } /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option is a: boolean type. * @@ -646,10 +648,12 @@ default SshEndpointConsumerBuilder failOnUnknownHost(boolean failOnUnknownHost) return this; } /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option will be converted to a boolean type. * @@ -732,8 +736,9 @@ default SshEndpointConsumerBuilder keyType(String keyType) { * Sets the resource path for a known_hosts file used to verify the SSH * server host key. The file uses the OpenSSH known_hosts format, and * its entries are matched against the host and port configured on the - * endpoint. When not set, the client does not verify the server host - * key against a known_hosts file. + * endpoint. A resource that cannot be read, or that has a line that + * cannot be read, makes every connection fail. When not set, the client + * does not verify the server host key against a known_hosts file. * * The option is a: java.lang.String type. * @@ -1348,10 +1353,12 @@ default SshEndpointProducerBuilder ciphers(String ciphers) { return this; } /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option is a: boolean type. * @@ -1366,10 +1373,12 @@ default SshEndpointProducerBuilder failOnUnknownHost(boolean failOnUnknownHost) return this; } /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option will be converted to a boolean type. * @@ -1452,8 +1461,9 @@ default SshEndpointProducerBuilder keyType(String keyType) { * Sets the resource path for a known_hosts file used to verify the SSH * server host key. The file uses the OpenSSH known_hosts format, and * its entries are matched against the host and port configured on the - * endpoint. When not set, the client does not verify the server host - * key against a known_hosts file. + * endpoint. A resource that cannot be read, or that has a line that + * cannot be read, makes every connection fail. When not set, the client + * does not verify the server host key against a known_hosts file. * * The option is a: java.lang.String type. * @@ -1961,10 +1971,12 @@ default SshEndpointBuilder ciphers(String ciphers) { return this; } /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option is a: boolean type. * @@ -1979,10 +1991,12 @@ default SshEndpointBuilder failOnUnknownHost(boolean failOnUnknownHost) { return this; } /** - * Specifies whether a connection to a server whose host has no entry in - * the known_hosts resource should fail. When false, such a server is - * accepted. A server whose host has entries is always rejected when it - * presents a key that none of them lists. + * Specifies whether a connection to a server whose host has no entry + * without a marker in the known_hosts resource should fail. When false, + * such a server is accepted. A server whose host has entries without a + * marker is always rejected when it presents a key that none of them + * lists, and a key listed in a matching revoked entry is always + * rejected. * * The option will be converted to a boolean type. * @@ -2065,8 +2079,9 @@ default SshEndpointBuilder keyType(String keyType) { * Sets the resource path for a known_hosts file used to verify the SSH * server host key. The file uses the OpenSSH known_hosts format, and * its entries are matched against the host and port configured on the - * endpoint. When not set, the client does not verify the server host - * key against a known_hosts file. + * endpoint. A resource that cannot be read, or that has a line that + * cannot be read, makes every connection fail. When not set, the client + * does not verify the server host key against a known_hosts file. * * The option is a: java.lang.String type. * From a1d570a52eefa979dea51853cadc2ad27af15017 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 14 Oct 2026 14:50:53 +0200 Subject: [PATCH 3/7] CAMEL-25644: camel-ssh - look up the host as configured and validate hashed entries The verifier took the host from the address the client connected to, whose host string writes an IPv6 address such as ::1 as 0:0:0:0:0:0:0:1, so an entry for [::1]:port was never found. The producer and the consumer now pass the host configured on the endpoint to the verifier, which uses it as written (without the brackets of an IPv6 address) with the port the client connected to. The existing constructors still use the connected address. A host field that starts with | must be a |1|salt|hash entry whose salt and hash both decode to 20 bytes; anything else makes the line unreadable, which makes every connection fail. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../camel/catalog/docs/ssh-component.adoc | 5 +- .../src/main/docs/ssh-component.adoc | 5 +- .../ssh/ResourceBasedSSHKeyVerifier.java | 40 ++++-- .../camel/component/ssh/SshConsumer.java | 2 +- .../camel/component/ssh/SshProducer.java | 2 +- .../ssh/ResourceBasedSSHKeyVerifierTest.java | 38 ++++- .../ssh/SshComponentKnownHostIPv6Test.java | 132 ++++++++++++++++++ .../pages/camel-4x-upgrade-guide-4_18.adoc | 7 +- .../pages/camel-4x-upgrade-guide-4_22.adoc | 7 +- .../pages/camel-4x-upgrade-guide-4_24.adoc | 7 +- 10 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc index d4c86c59f38f5..8317b473d008b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc @@ -176,8 +176,9 @@ The entries are matched as OpenSSH matches them: on the endpoint, written `host` for port 22 and `[host]:port` for another port. `*` and `?` are wildcards, a pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. So `db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every -host and port. The configured value is used as is: an endpoint configured with an address only uses -the entries for that address. +host and port. The configured value is used as it is written: an endpoint configured with an address +only uses the entries for that address, and an IPv6 address such as `::1` (or `[::1]` in the endpoint +uri) is not expanded. * The server is accepted when an entry of its host lists the key it presents. A host can be listed with several keys, one per line. * The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when diff --git a/components/camel-ssh/src/main/docs/ssh-component.adoc b/components/camel-ssh/src/main/docs/ssh-component.adoc index d4c86c59f38f5..8317b473d008b 100644 --- a/components/camel-ssh/src/main/docs/ssh-component.adoc +++ b/components/camel-ssh/src/main/docs/ssh-component.adoc @@ -176,8 +176,9 @@ The entries are matched as OpenSSH matches them: on the endpoint, written `host` for port 22 and `[host]:port` for another port. `*` and `?` are wildcards, a pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. So `db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every -host and port. The configured value is used as is: an endpoint configured with an address only uses -the entries for that address. +host and port. The configured value is used as it is written: an endpoint configured with an address +only uses the entries for that address, and an IPv6 address such as `::1` (or `[::1]` in the endpoint +uri) is not expanded. * The server is accepted when an entry of its host lists the key it presents. A host can be listed with several keys, one per line. * The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java index 526ce1fe95c52..ec8863443448d 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java @@ -62,13 +62,14 @@ public class ResourceBasedSSHKeyVerifier implements ServerKeyVerifier { private static final String MARKER_CERT_AUTHORITY = "@cert-authority"; private static final String HASHED_HOST_PREFIX = "|1|"; private static final String HASH_ALGORITHM = "HmacSHA1"; - private static final int HASH_SALT_LENGTH = 20; + private static final int HASH_LENGTH = 20; protected final Logger log = LoggerFactory.getLogger(getClass()); private CamelContext camelContext; private boolean failOnUnknownHost; private String knownHostsResource; + private String host; public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource) { this(camelContext, knownHostsResource, false); @@ -76,9 +77,20 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource, boolean failOnUnknownHost) { + this(camelContext, knownHostsResource, failOnUnknownHost, null); + } + + /** + * @param host the host name or address configured on the endpoint, which the entries are matched against as it is + * written (an IPv6 address may be enclosed in brackets). When null, the host of the address the client + * connected to is used, and an IPv6 address is then written in its full form. + */ + public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource, + boolean failOnUnknownHost, String host) { this.camelContext = camelContext; this.knownHostsResource = knownHostsResource; this.failOnUnknownHost = failOnUnknownHost; + this.host = host; } @Override @@ -93,7 +105,7 @@ public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress rem return false; } - String server = toKnownHostsName(remoteAddress); + String server = toKnownHostsName(host, remoteAddress); boolean hostListed = false; boolean keyListed = false; int lineNumber = 0; @@ -156,16 +168,22 @@ private boolean isSameKey(KnownHostsEntry entry, PublicKey serverKey) { } /* - * The name OpenSSH looks the server up by: the host name or address the client connected to, as configured on the - * endpoint and never resolved, written host for port 22 and [host]:port for another port. + * The name OpenSSH looks the server up by: the host name or address configured on the endpoint, as it is written + * and never resolved, with the port the client connected to, written host for port 22 and [host]:port for another + * port. */ - private static String toKnownHostsName(SocketAddress remoteAddress) { + private static String toKnownHostsName(String configuredHost, SocketAddress remoteAddress) { if (!(remoteAddress instanceof InetSocketAddress) && !(remoteAddress instanceof SshdSocketAddress)) { return null; } SshdSocketAddress address = SshdSocketAddress.toSshdSocketAddress(remoteAddress); - String host = address.getHostName().toLowerCase(Locale.ROOT); - return address.getPort() == DEFAULT_PORT ? host : "[" + host + "]:" + address.getPort(); + String name = configuredHost != null ? configuredHost : address.getHostName(); + if (name.length() > 1 && name.charAt(0) == '[' && name.charAt(name.length() - 1) == ']') { + // an IPv6 address in an endpoint uri + name = name.substring(1, name.length() - 1); + } + name = name.toLowerCase(Locale.ROOT); + return address.getPort() == DEFAULT_PORT ? name : "[" + name + "]:" + address.getPort(); } /* @@ -238,14 +256,18 @@ static KnownHostsEntry parse(String line, SessionContext session) { try { byte[] salt = null; byte[] hash = null; - if (hosts.startsWith(HASHED_HOST_PREFIX)) { + if (hosts.startsWith("|")) { + // a hashed host: only |1|salt|hash, with a SHA-1 salt and hash, is supported + if (!hosts.startsWith(HASHED_HOST_PREFIX)) { + return null; + } String[] parts = hosts.substring(HASHED_HOST_PREFIX.length()).split("\\|", -1); if (parts.length != 2) { return null; } salt = Base64.getDecoder().decode(parts[0]); hash = Base64.getDecoder().decode(parts[1]); - if (salt.length != HASH_SALT_LENGTH) { + if (salt.length != HASH_LENGTH || hash.length != HASH_LENGTH) { return null; } } diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java index b59c26712761a..df136834b5a29 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java @@ -61,7 +61,7 @@ protected int poll() throws Exception { if (knownHostResource != null) { client.setServerKeyVerifier(new ResourceBasedSSHKeyVerifier( exchange.getContext(), knownHostResource, - endpoint.isFailOnUnknownHost())); + endpoint.isFailOnUnknownHost(), endpoint.getHost())); } SshResult result = SshHelper.sendExecCommand(exchange.getIn().getHeaders(), command, endpoint, client); diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java index 850bb794d2e85..6ffd0db5e90ec 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java @@ -68,7 +68,7 @@ public void process(Exchange exchange) throws Exception { if (knownHostResource != null) { client.setServerKeyVerifier(new ResourceBasedSSHKeyVerifier( exchange.getContext(), knownHostResource, - endpoint.isFailOnUnknownHost())); + endpoint.isFailOnUnknownHost(), endpoint.getHost())); } SshResult result = SshHelper.sendExecCommand(headers, command, endpoint, client); diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java index 06798db788f75..9c9992ba251f1 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java @@ -186,12 +186,17 @@ void treatsAHostWithOnlyRevokedEntriesAsUnknown() throws Exception { @Test void rejectsEveryServerWhenALineCannotBeRead() throws Exception { + String key = PublicKeyEntry.toString(serverKey); String[] unreadableLines = { - "@unknown db.example.com " + PublicKeyEntry.toString(serverKey), + "@unknown db.example.com " + key, "@revoked", "db.example.com", "db.example.com ssh-ed25519 not*base64", - "|1|c2FsdA==|aGFzaA== " + PublicKeyEntry.toString(serverKey) }; + "|1|c2FsdA==|aGFzaA== " + key, + "|1|" + base64(20) + "| " + key, + "|1|" + base64(20) + "|" + base64(19) + " " + key, + "|1|" + base64(20) + "|" + base64(21) + " " + key, + "|2|" + base64(20) + "|" + base64(20) + " " + key }; for (String line : unreadableLines) { for (String knownHosts : new String[] { line + "\n", entry("db.example.com", serverKey) + line + "\n" }) { for (boolean failOnUnknownHost : new boolean[] { true, false }) { @@ -241,6 +246,22 @@ void looksUpAnAddressAsConfigured() throws Exception { assertTrue(verify(true, new InetSocketAddress("127.0.0.1", 22), serverKey, knownHosts)); } + @Test + void looksUpTheHostAsConfiguredOnTheEndpoint() throws Exception { + // the address the client connected to writes ::1 as 0:0:0:0:0:0:0:1 + InetSocketAddress server = new InetSocketAddress("::1", 2222); + String knownHosts = entry("[::1]:2222", serverKey); + for (String host : new String[] { "::1", "[::1]" }) { + for (boolean failOnUnknownHost : new boolean[] { true, false }) { + assertTrue(verify(failOnUnknownHost, host, server, serverKey, knownHosts), host); + assertFalse(verify(failOnUnknownHost, host, server, otherKey, knownHosts), host); + } + } + InetSocketAddress address = new InetSocketAddress(InetAddress.getByAddress("10.0.0.1", new byte[] { 10, 0, 0, 1 }), 22); + assertTrue(verify(true, "DB.example.com", address, serverKey, entry("db.example.com", serverKey))); + assertFalse(verify(true, "db.example.com", address, serverKey, entry("10.0.0.1", serverKey))); + } + @Test void verifiesAllSupportedKeyTypes() throws Exception { ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(context, "classpath:known_hosts_types", true); @@ -266,13 +287,24 @@ private boolean verify(boolean failOnUnknownHost, String host, int port, PublicK private boolean verify( boolean failOnUnknownHost, InetSocketAddress server, PublicKey presented, String knownHosts) throws Exception { + return verify(failOnUnknownHost, null, server, presented, knownHosts); + } + + private boolean verify( + boolean failOnUnknownHost, String configuredHost, InetSocketAddress server, PublicKey presented, + String knownHosts) + throws Exception { Path file = Files.createTempFile(dir, "known_hosts", ""); Files.writeString(file, knownHosts, StandardCharsets.UTF_8); ResourceBasedSSHKeyVerifier verifier - = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost); + = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost, configuredHost); return verifier.verifyServerKey(null, server, presented); } + private static String base64(int length) { + return Base64.getEncoder().encodeToString(new byte[length]); + } + private static String entry(String hosts, PublicKey key) { return hosts + " " + PublicKeyEntry.toString(key) + "\n"; } diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java new file mode 100644 index 0000000000000..43e943543e044 --- /dev/null +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.camel.component.ssh; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; + +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * An endpoint configured with the IPv6 address ::1 is looked up as [::1]:port, as it is configured, although the + * address the client connects to writes it as 0:0:0:0:0:0:0:1. The host is set on the endpoint, as the endpoint uri + * does not accept an IPv6 address. + */ +class SshComponentKnownHostIPv6Test extends SshComponentTestSupport { + + private String knownHostsServerKey; + private String knownHostsOtherKey; + + @Override + public void doPreSetup() throws Exception { + super.doPreSetup(); + assumeTrue(isIPv6LoopbackAvailable(), "The IPv6 loopback address is not available"); + knownHostsServerKey = writeKnownHosts("known_hosts_server_key", "[::1]:" + port + " " + serverPublicKey()); + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + String otherKey = PublicKeyEntry.toString(generator.generateKeyPair().getPublic()); + knownHostsOtherKey = writeKnownHosts("known_hosts_other_key", "[::1]:" + port + " " + otherKey); + } + + @Test + void testProducerWithTheServerKey() throws Exception { + assertDelivered("direct:serverKey"); + } + + @Test + void testProducerWithTheServerKeyAcceptingUnknownHosts() throws Exception { + assertDelivered("direct:serverKeyAcceptingUnknownHosts"); + } + + @Test + void testProducerWithAnotherKey() throws Exception { + assertRejected("direct:otherKey"); + } + + @Test + void testProducerWithAnotherKeyAcceptingUnknownHosts() throws Exception { + assertRejected("direct:otherKeyAcceptingUnknownHosts"); + } + + private void assertDelivered(String endpoint) throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(1); + mock.expectedBodiesReceived("test"); + getMockEndpoint("mock:error").expectedMessageCount(0); + + template.sendBody(endpoint, "test"); + + MockEndpoint.assertIsSatisfied(context); + } + + private void assertRejected(String endpoint) throws Exception { + getMockEndpoint("mock:result").expectedMessageCount(0); + getMockEndpoint("mock:error").expectedMessageCount(1); + + template.sendBody(endpoint, "test"); + + MockEndpoint.assertIsSatisfied(context); + } + + private boolean isIPv6LoopbackAvailable() { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("::1", port), 2000); + return true; + } catch (IOException e) { + return false; + } + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + onException(Exception.class).handled(true).to("mock:error"); + + from("direct:serverKey") + .to(ipv6Endpoint(knownHostsServerKey, true)) + .to("mock:result"); + from("direct:serverKeyAcceptingUnknownHosts") + .to(ipv6Endpoint(knownHostsServerKey, false)) + .to("mock:result"); + from("direct:otherKey") + .to(ipv6Endpoint(knownHostsOtherKey, true)) + .to("mock:result"); + from("direct:otherKeyAcceptingUnknownHosts") + .to(ipv6Endpoint(knownHostsOtherKey, false)) + .to("mock:result"); + } + }; + } + + private SshEndpoint ipv6Endpoint(String knownHosts, boolean failOnUnknownHost) { + SshEndpoint endpoint = context.getEndpoint("ssh://smx:smx@localhost:" + port + "?timeout=3000&knownHostsResource=" + + knownHosts + "&failOnUnknownHost=" + failOnUnknownHost, + SshEndpoint.class); + endpoint.setHost("::1"); + return endpoint; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index b72a315af502c..81400c95fae25 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -47,9 +47,10 @@ and `[host]:port` for another port: contains the configured one is no longer used. * A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The configured host is used as is. An endpoint configured with a host name no longer uses entries -listed only under its address, and an endpoint configured with an address no longer uses entries -listed under the name that a reverse lookup of the address returns. +* The configured host is used as it is written. An endpoint configured with a host name no longer uses +entries listed only under its address, an endpoint configured with an address no longer uses entries +listed under the name that a reverse lookup of the address returns, and an IPv6 address such as `::1` +is matched as written instead of in its expanded form. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index c33015a960c04..a8bb971cf37a9 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -150,9 +150,10 @@ and `[host]:port` for another port: contains the configured one is no longer used. * A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The configured host is used as is. An endpoint configured with a host name no longer uses entries -listed only under its address, and an endpoint configured with an address no longer uses entries -listed under the name that a reverse lookup of the address returns. +* The configured host is used as it is written. An endpoint configured with a host name no longer uses +entries listed only under its address, an endpoint configured with an address no longer uses entries +listed under the name that a reverse lookup of the address returns, and an IPv6 address such as `::1` +is matched as written instead of in its expanded form. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 231e16f815ce7..520ec050f929f 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -107,9 +107,10 @@ and `[host]:port` for another port: contains the configured one is no longer used. * A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The configured host is used as is. An endpoint configured with a host name no longer uses entries -listed only under its address, and an endpoint configured with an address no longer uses entries -listed under the name that a reverse lookup of the address returns. +* The configured host is used as it is written. An endpoint configured with a host name no longer uses +entries listed only under its address, an endpoint configured with an address no longer uses entries +listed under the name that a reverse lookup of the address returns, and an IPv6 address such as `::1` +is matched as written instead of in its expanded form. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. From 1d26dcebcfa68684cf25793d35a6d33e264f8e7f Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Thu, 15 Oct 2026 10:18:26 +0200 Subject: [PATCH 4/7] CAMEL-25644: camel-ssh - look up the host name the client connects to When ~/.ssh/config gives a HostName for the host configured on the endpoint, SSHD connects to that HostName, and OpenSSH looks the server up by it. The verifier now uses the host name or address the client connects to, never resolved through DNS, as before the previous commit. Only an IPv6 address configured on the endpoint keeps its written spelling, because the connected address writes it in its full form. The docs no longer say that an IPv6 address can be given in the endpoint uri, which does not accept one. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../camel/catalog/docs/ssh-component.adoc | 14 +++--- .../src/main/docs/ssh-component.adoc | 14 +++--- .../ssh/ResourceBasedSSHKeyVerifier.java | 44 +++++++++++-------- .../ssh/ResourceBasedSSHKeyVerifierTest.java | 15 +++++-- .../pages/camel-4x-upgrade-guide-4_18.adoc | 13 +++--- .../pages/camel-4x-upgrade-guide-4_22.adoc | 13 +++--- .../pages/camel-4x-upgrade-guide-4_24.adoc | 13 +++--- 7 files changed, 72 insertions(+), 54 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc index 8317b473d008b..e1b2e38de453a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc @@ -172,13 +172,13 @@ When it is `false` (the default), such a server is accepted and a warning is log The entries are matched as OpenSSH matches them: -* Each host pattern of an entry is compared, ignoring case, with the host name or address configured -on the endpoint, written `host` for port 22 and `[host]:port` for another port. `*` and `?` are -wildcards, a pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. -So `db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every -host and port. The configured value is used as it is written: an endpoint configured with an address -only uses the entries for that address, and an IPv6 address such as `::1` (or `[::1]` in the endpoint -uri) is not expanded. +* Each host pattern of an entry is compared, ignoring case, with the host name or address the client +connects to: the host configured on the endpoint, or the `HostName` that `~/.ssh/config` gives for it. +It is written `host` for port 22 and `[host]:port` for another port. `*` and `?` are wildcards, a +pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. So +`db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every host +and port. The host is never resolved: an endpoint configured with an address only uses the entries for +that address, and an IPv6 address such as `::1` is not expanded. * The server is accepted when an entry of its host lists the key it presents. A host can be listed with several keys, one per line. * The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when diff --git a/components/camel-ssh/src/main/docs/ssh-component.adoc b/components/camel-ssh/src/main/docs/ssh-component.adoc index 8317b473d008b..e1b2e38de453a 100644 --- a/components/camel-ssh/src/main/docs/ssh-component.adoc +++ b/components/camel-ssh/src/main/docs/ssh-component.adoc @@ -172,13 +172,13 @@ When it is `false` (the default), such a server is accepted and a warning is log The entries are matched as OpenSSH matches them: -* Each host pattern of an entry is compared, ignoring case, with the host name or address configured -on the endpoint, written `host` for port 22 and `[host]:port` for another port. `*` and `?` are -wildcards, a pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. -So `db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every -host and port. The configured value is used as it is written: an endpoint configured with an address -only uses the entries for that address, and an IPv6 address such as `::1` (or `[::1]` in the endpoint -uri) is not expanded. +* Each host pattern of an entry is compared, ignoring case, with the host name or address the client +connects to: the host configured on the endpoint, or the `HostName` that `~/.ssh/config` gives for it. +It is written `host` for port 22 and `[host]:port` for another port. `*` and `?` are wildcards, a +pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. So +`db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every host +and port. The host is never resolved: an endpoint configured with an address only uses the entries for +that address, and an IPv6 address such as `::1` is not expanded. * The server is accepted when an entry of its host lists the key it presents. A host can be listed with several keys, one per line. * The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java index ec8863443448d..6d57e01c40443 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java @@ -47,14 +47,15 @@ * ServerKeyVerifier that takes a camel resource as input file to validate the server key against. *

* The resource uses the OpenSSH known_hosts format, and its entries are matched as OpenSSH matches them. Each host - * pattern of an entry is compared, ignoring case, with the host name or address configured on the endpoint, written - * {@code host} for port 22 and {@code [host]:port} for another port: {@code *} and {@code ?} are wildcards, a pattern - * starting with {@code !} excludes the hosts it matches, and hashed entries are supported. The server is accepted when - * an entry of its host lists the key it presents. It is rejected when that key is listed in a matching {@code @revoked} - * entry, or when its host has entries without a marker but none of them lists that key. {@code failOnUnknownHost} only - * decides for a host without such an entry: {@code @revoked} and {@code @cert-authority} entries do not list a host, - * and host certificates are not supported. A resource that cannot be read, or that has a line that cannot be read, - * rejects every server. + * pattern of an entry is compared, ignoring case, with the host name or address the client connects to (the host + * configured on the endpoint, or the {@code HostName} that {@code ~/.ssh/config} gives for it, never resolved through + * DNS; an IPv6 address configured on the endpoint is used as it is written), written {@code host} for port 22 and + * {@code [host]:port} for another port: {@code *} and {@code ?} are wildcards, a pattern starting with {@code !} + * excludes the hosts it matches, and hashed entries are supported. The server is accepted when an entry of its host + * lists the key it presents. It is rejected when that key is listed in a matching {@code @revoked} entry, or when its + * host has entries without a marker but none of them lists that key. {@code failOnUnknownHost} only decides for a host + * without such an entry: {@code @revoked} and {@code @cert-authority} entries do not list a host, and host certificates + * are not supported. A resource that cannot be read, or that has a line that cannot be read, rejects every server. */ public class ResourceBasedSSHKeyVerifier implements ServerKeyVerifier { private static final int DEFAULT_PORT = 22; @@ -81,9 +82,10 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR } /** - * @param host the host name or address configured on the endpoint, which the entries are matched against as it is - * written (an IPv6 address may be enclosed in brackets). When null, the host of the address the client - * connected to is used, and an IPv6 address is then written in its full form. + * @param host the host name or address configured on the endpoint. The entries are matched against the host name or + * address the client connects to, as SSHD resolved it from the endpoint and {@code ~/.ssh/config}, + * which writes an IPv6 address in its full form. When the configured host is an IPv6 address (it may be + * enclosed in brackets), it is used as it is written instead. May be null. */ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource, boolean failOnUnknownHost, String host) { @@ -168,19 +170,25 @@ private boolean isSameKey(KnownHostsEntry entry, PublicKey serverKey) { } /* - * The name OpenSSH looks the server up by: the host name or address configured on the endpoint, as it is written - * and never resolved, with the port the client connected to, written host for port 22 and [host]:port for another - * port. + * The name OpenSSH looks the server up by: the host name or address the client connected to, as SSHD resolved it + * from the endpoint and ~/.ssh/config (the HostName there), never resolved through DNS, with the port the client + * connected to, written host for port 22 and [host]:port for another port. The connected address writes an IPv6 + * address in its full form, so an IPv6 address configured on the endpoint is used as it is written. */ private static String toKnownHostsName(String configuredHost, SocketAddress remoteAddress) { if (!(remoteAddress instanceof InetSocketAddress) && !(remoteAddress instanceof SshdSocketAddress)) { return null; } SshdSocketAddress address = SshdSocketAddress.toSshdSocketAddress(remoteAddress); - String name = configuredHost != null ? configuredHost : address.getHostName(); - if (name.length() > 1 && name.charAt(0) == '[' && name.charAt(name.length() - 1) == ']') { - // an IPv6 address in an endpoint uri - name = name.substring(1, name.length() - 1); + String name = address.getHostName(); + if (configuredHost != null) { + String configured = configuredHost; + if (configured.length() > 1 && configured.charAt(0) == '[' && configured.charAt(configured.length() - 1) == ']') { + configured = configured.substring(1, configured.length() - 1); + } + if (configured.indexOf(':') >= 0) { + name = configured; + } } name = name.toLowerCase(Locale.ROOT); return address.getPort() == DEFAULT_PORT ? name : "[" + name + "]:" + address.getPort(); diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java index 9c9992ba251f1..b98282416f9c9 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java @@ -247,7 +247,7 @@ void looksUpAnAddressAsConfigured() throws Exception { } @Test - void looksUpTheHostAsConfiguredOnTheEndpoint() throws Exception { + void looksUpAnIpv6AddressAsConfiguredOnTheEndpoint() throws Exception { // the address the client connected to writes ::1 as 0:0:0:0:0:0:0:1 InetSocketAddress server = new InetSocketAddress("::1", 2222); String knownHosts = entry("[::1]:2222", serverKey); @@ -257,9 +257,16 @@ void looksUpTheHostAsConfiguredOnTheEndpoint() throws Exception { assertFalse(verify(failOnUnknownHost, host, server, otherKey, knownHosts), host); } } - InetSocketAddress address = new InetSocketAddress(InetAddress.getByAddress("10.0.0.1", new byte[] { 10, 0, 0, 1 }), 22); - assertTrue(verify(true, "DB.example.com", address, serverKey, entry("db.example.com", serverKey))); - assertFalse(verify(true, "db.example.com", address, serverKey, entry("10.0.0.1", serverKey))); + } + + @Test + void looksUpTheHostNameTheClientConnectsTo() throws Exception { + // as OpenSSH does: the HostName that ~/.ssh/config gives for the configured host db is db.example.com + InetSocketAddress server + = new InetSocketAddress(InetAddress.getByAddress("db.example.com", new byte[] { 10, 0, 0, 1 }), 22); + assertTrue(verify(true, "db", server, serverKey, entry("db.example.com", serverKey))); + assertFalse(verify(true, "db", server, serverKey, entry("db", serverKey))); + assertFalse(verify(false, "db", server, otherKey, entry("db.example.com", serverKey))); } @Test diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index 81400c95fae25..fb39e32705042 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -40,17 +40,18 @@ without a callback secret. The value of such an option could not be read back fr === camel-ssh - known_hosts entries are matched as in OpenSSH When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address configured on the endpoint, written `host` for port 22 -and `[host]:port` for another port: +matches them, against the host name or address the client connects to, written `host` for port 22 and +`[host]:port` for another port: * A host pattern must match that name as a whole. An entry for another host whose name or address contains the configured one is no longer used. * A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The configured host is used as it is written. An endpoint configured with a host name no longer uses -entries listed only under its address, an endpoint configured with an address no longer uses entries -listed under the name that a reverse lookup of the address returns, and an IPv6 address such as `::1` -is matched as written instead of in its expanded form. +* The host is used as it is written: the host configured on the endpoint, or the `HostName` that +`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed +only under its address, an endpoint configured with an address no longer uses entries listed under the +name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as +written instead of in its expanded form. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index a8bb971cf37a9..8d33215a1b33b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -143,17 +143,18 @@ the producer. === camel-ssh - known_hosts entries are matched as in OpenSSH When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address configured on the endpoint, written `host` for port 22 -and `[host]:port` for another port: +matches them, against the host name or address the client connects to, written `host` for port 22 and +`[host]:port` for another port: * A host pattern must match that name as a whole. An entry for another host whose name or address contains the configured one is no longer used. * A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The configured host is used as it is written. An endpoint configured with a host name no longer uses -entries listed only under its address, an endpoint configured with an address no longer uses entries -listed under the name that a reverse lookup of the address returns, and an IPv6 address such as `::1` -is matched as written instead of in its expanded form. +* The host is used as it is written: the host configured on the endpoint, or the `HostName` that +`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed +only under its address, an endpoint configured with an address no longer uses entries listed under the +name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as +written instead of in its expanded form. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 520ec050f929f..e36d98bc61a8d 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -100,17 +100,18 @@ differently is now read with its own definition. === camel-ssh - known_hosts entries are matched as in OpenSSH When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address configured on the endpoint, written `host` for port 22 -and `[host]:port` for another port: +matches them, against the host name or address the client connects to, written `host` for port 22 and +`[host]:port` for another port: * A host pattern must match that name as a whole. An entry for another host whose name or address contains the configured one is no longer used. * A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The configured host is used as it is written. An endpoint configured with a host name no longer uses -entries listed only under its address, an endpoint configured with an address no longer uses entries -listed under the name that a reverse lookup of the address returns, and an IPv6 address such as `::1` -is matched as written instead of in its expanded form. +* The host is used as it is written: the host configured on the endpoint, or the `HostName` that +`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed +only under its address, an endpoint configured with an address no longer uses entries listed under the +name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as +written instead of in its expanded form. * Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are ignored. From ef7e70d36ac7475bf40a025c234c36c439a74fb4 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Thu, 15 Oct 2026 13:34:37 +0200 Subject: [PATCH 5/7] CAMEL-25644: camel-ssh - look up the HostName the host configuration gives An alias in ~/.ssh/config whose HostName is an IPv6 address, such as HostName ::1, was looked up through the connected address, which writes the address in its full form, so an entry for [::1]:port was not found. The verifier now resolves the configured host through the host configuration that the client of the session used for the connection (~/.ssh/config by default, or the resolver of a custom client builder), and uses the HostName it gives, as written; without an entry, it uses the configured host as written. The connection tests cover an alias resolving to ::1 and an alias resolving to a host name, and assert that a server whose key is rejected receives no credentials. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../ssh/ResourceBasedSSHKeyVerifier.java | 62 +++++++----- .../ssh/ResourceBasedSSHKeyVerifierTest.java | 63 ++++++++++-- .../ssh/SshComponentKnownHostIPv6Test.java | 95 +++++++++++++------ .../ssh/SshComponentKnownHostTest.java | 47 +++++++++ .../ssh/SshComponentTestSupport.java | 16 +++- 5 files changed, 221 insertions(+), 62 deletions(-) diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java index 6d57e01c40443..e8b83930bbb74 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java @@ -33,6 +33,9 @@ import org.apache.camel.CamelContext; import org.apache.camel.support.ResourceHelper; +import org.apache.sshd.client.ClientFactoryManager; +import org.apache.sshd.client.config.hosts.HostConfigEntry; +import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.config.keys.KeyUtils; @@ -48,9 +51,9 @@ *

* The resource uses the OpenSSH known_hosts format, and its entries are matched as OpenSSH matches them. Each host * pattern of an entry is compared, ignoring case, with the host name or address the client connects to (the host - * configured on the endpoint, or the {@code HostName} that {@code ~/.ssh/config} gives for it, never resolved through - * DNS; an IPv6 address configured on the endpoint is used as it is written), written {@code host} for port 22 and - * {@code [host]:port} for another port: {@code *} and {@code ?} are wildcards, a pattern starting with {@code !} + * configured on the endpoint, or the {@code HostName} that {@code ~/.ssh/config} gives for it, as it is written and + * never resolved through DNS, so an IPv6 address such as {@code ::1} is not expanded), written {@code host} for port 22 + * and {@code [host]:port} for another port: {@code *} and {@code ?} are wildcards, a pattern starting with {@code !} * excludes the hosts it matches, and hashed entries are supported. The server is accepted when an entry of its host * lists the key it presents. It is rejected when that key is listed in a matching {@code @revoked} entry, or when its * host has entries without a marker but none of them lists that key. {@code failOnUnknownHost} only decides for a host @@ -82,10 +85,11 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR } /** - * @param host the host name or address configured on the endpoint. The entries are matched against the host name or - * address the client connects to, as SSHD resolved it from the endpoint and {@code ~/.ssh/config}, - * which writes an IPv6 address in its full form. When the configured host is an IPv6 address (it may be - * enclosed in brackets), it is used as it is written instead. May be null. + * @param host the host name or address configured on the endpoint. The entries are matched against the + * {@code HostName} that the host configuration of the client (by default {@code ~/.ssh/config}) gives + * for it, or else against the configured host, as it is written: an IPv6 address such as {@code ::1} is + * not expanded. When null, the address the client connected to is used, which writes an IPv6 address in + * its full form. */ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource, boolean failOnUnknownHost, String host) { @@ -107,7 +111,7 @@ public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress rem return false; } - String server = toKnownHostsName(host, remoteAddress); + String server = toKnownHostsName(sshClientSession, remoteAddress); boolean hostListed = false; boolean keyListed = false; int lineNumber = 0; @@ -170,30 +174,44 @@ private boolean isSameKey(KnownHostsEntry entry, PublicKey serverKey) { } /* - * The name OpenSSH looks the server up by: the host name or address the client connected to, as SSHD resolved it - * from the endpoint and ~/.ssh/config (the HostName there), never resolved through DNS, with the port the client - * connected to, written host for port 22 and [host]:port for another port. The connected address writes an IPv6 - * address in its full form, so an IPv6 address configured on the endpoint is used as it is written. + * The name OpenSSH looks the server up by: the host name or address the client connected to, never resolved through + * DNS, with the port the client connected to, written host for port 22 and [host]:port for another port. */ - private static String toKnownHostsName(String configuredHost, SocketAddress remoteAddress) { + private String toKnownHostsName(ClientSession session, SocketAddress remoteAddress) { if (!(remoteAddress instanceof InetSocketAddress) && !(remoteAddress instanceof SshdSocketAddress)) { return null; } SshdSocketAddress address = SshdSocketAddress.toSshdSocketAddress(remoteAddress); - String name = address.getHostName(); - if (configuredHost != null) { - String configured = configuredHost; - if (configured.length() > 1 && configured.charAt(0) == '[' && configured.charAt(configured.length() - 1) == ']') { - configured = configured.substring(1, configured.length() - 1); - } - if (configured.indexOf(':') >= 0) { - name = configured; - } + String name = host != null ? effectiveHostName(session, address.getPort()) : address.getHostName(); + if (name.length() > 1 && name.charAt(0) == '[' && name.charAt(name.length() - 1) == ']') { + // an IPv6 address in brackets + name = name.substring(1, name.length() - 1); } name = name.toLowerCase(Locale.ROOT); return address.getPort() == DEFAULT_PORT ? name : "[" + name + "]:" + address.getPort(); } + /* + * The host name or address the client connected to, as it is written: the HostName that the host config resolver + * of the client (by default ~/.ssh/config) gives for the configured host, or else the configured host. The connected + * address cannot be used for this, as it writes an IPv6 address such as ::1 in its full form. + */ + private String effectiveHostName(ClientSession session, int port) { + ClientFactoryManager client = session != null ? session.getFactoryManager() : null; + HostConfigEntryResolver resolver = client != null ? client.getHostConfigEntryResolver() : null; + if (resolver != null) { + try { + HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, null, session.getUsername(), null, null); + if (entry != null && entry.getHostName() != null && !entry.getHostName().isEmpty()) { + return entry.getHostName(); + } + } catch (IOException | RuntimeException e) { + log.warn("Could not resolve the host {} with the host configuration of the client", host, e); + } + } + return host; + } + /* * Matches a pattern where * stands for any sequence of characters and ? for any single character. */ diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java index b98282416f9c9..ed46c10f95175 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java @@ -17,6 +17,8 @@ package org.apache.camel.component.ssh; import java.io.InputStream; +import java.lang.reflect.Array; +import java.lang.reflect.Proxy; import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; @@ -34,6 +36,10 @@ import org.apache.camel.CamelContext; import org.apache.camel.impl.DefaultCamelContext; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.config.hosts.HostConfigEntry; +import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; +import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.util.io.IoUtils; @@ -260,13 +266,27 @@ void looksUpAnIpv6AddressAsConfiguredOnTheEndpoint() throws Exception { } @Test - void looksUpTheHostNameTheClientConnectsTo() throws Exception { - // as OpenSSH does: the HostName that ~/.ssh/config gives for the configured host db is db.example.com - InetSocketAddress server + void looksUpTheHostNameThatTheHostConfigurationGivesForTheConfiguredHost() throws Exception { + // as OpenSSH does with ~/.ssh/config: Host db, HostName db.example.com and Host review-ipv6, HostName ::1 + ClientSession session = sessionResolving((host, port, localAddress, username, proxyJump, attributes) -> { + if ("db".equals(host)) { + return new HostConfigEntry(host, "db.example.com", port, username); + } + return "review-ipv6".equals(host) ? new HostConfigEntry(host, "::1", port, username) : null; + }); + InetSocketAddress hostName = new InetSocketAddress(InetAddress.getByAddress("db.example.com", new byte[] { 10, 0, 0, 1 }), 22); - assertTrue(verify(true, "db", server, serverKey, entry("db.example.com", serverKey))); - assertFalse(verify(true, "db", server, serverKey, entry("db", serverKey))); - assertFalse(verify(false, "db", server, otherKey, entry("db.example.com", serverKey))); + assertTrue(verify(true, "db", session, hostName, serverKey, entry("db.example.com", serverKey))); + assertFalse(verify(true, "db", session, hostName, serverKey, entry("db", serverKey))); + assertFalse(verify(false, "db", session, hostName, otherKey, entry("db.example.com", serverKey))); + // the address the client connected to writes ::1 as 0:0:0:0:0:0:0:1 + InetSocketAddress ipv6 = new InetSocketAddress("::1", 2222); + for (boolean failOnUnknownHost : new boolean[] { true, false }) { + assertTrue(verify(failOnUnknownHost, "review-ipv6", session, ipv6, serverKey, entry("[::1]:2222", serverKey))); + assertFalse(verify(failOnUnknownHost, "review-ipv6", session, ipv6, otherKey, entry("[::1]:2222", serverKey))); + } + // a host without an entry in the host configuration is looked up as it is configured + assertTrue(verify(true, "::1", session, ipv6, serverKey, entry("[::1]:2222", serverKey))); } @Test @@ -301,11 +321,40 @@ private boolean verify( boolean failOnUnknownHost, String configuredHost, InetSocketAddress server, PublicKey presented, String knownHosts) throws Exception { + return verify(failOnUnknownHost, configuredHost, null, server, presented, knownHosts); + } + + private boolean verify( + boolean failOnUnknownHost, String configuredHost, ClientSession session, InetSocketAddress server, + PublicKey presented, String knownHosts) + throws Exception { Path file = Files.createTempFile(dir, "known_hosts", ""); Files.writeString(file, knownHosts, StandardCharsets.UTF_8); ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost, configuredHost); - return verifier.verifyServerKey(null, server, presented); + return verifier.verifyServerKey(session, server, presented); + } + + /* + * A session of a client with the given host configuration, which is all the verifier asks the session for. + */ + private static ClientSession sessionResolving(HostConfigEntryResolver resolver) { + SshClient client = SshClient.setUpDefaultClient(); + client.setHostConfigEntryResolver(resolver); + return (ClientSession) Proxy.newProxyInstance(ClientSession.class.getClassLoader(), + new Class[] { ClientSession.class }, (proxy, method, args) -> { + switch (method.getName()) { + case "getFactoryManager": + return client; + case "getUsername": + return "smx"; + default: + Class type = method.getReturnType(); + // the default value of a primitive type, null otherwise + return type.isPrimitive() && type != void.class + ? Array.get(Array.newInstance(type, 1), 0) : null; + } + }); } private static String base64(int length) { diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java index 43e943543e044..bf1e2a648efd5 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java @@ -24,18 +24,25 @@ import org.apache.camel.builder.RouteBuilder; import org.apache.camel.component.mock.MockEndpoint; +import org.apache.sshd.client.ClientBuilder; +import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** - * An endpoint configured with the IPv6 address ::1 is looked up as [::1]:port, as it is configured, although the - * address the client connects to writes it as 0:0:0:0:0:0:0:1. The host is set on the endpoint, as the endpoint uri - * does not accept an IPv6 address. + * A server reached at the IPv6 address ::1 is looked up as [::1]:port, as the address is written, although the address + * the client connects to writes it as 0:0:0:0:0:0:0:1: both when the endpoint is configured with ::1, and when the + * endpoint is configured with an alias that the host configuration of the client (as ~/.ssh/config would) gives the + * HostName ::1 for. The host is set on the endpoint, as the endpoint uri does not accept an IPv6 address. */ class SshComponentKnownHostIPv6Test extends SshComponentTestSupport { + private static final String ALIAS = "review-ipv6"; + private String knownHostsServerKey; private String knownHostsOtherKey; @@ -52,42 +59,67 @@ public void doPreSetup() throws Exception { @Test void testProducerWithTheServerKey() throws Exception { - assertDelivered("direct:serverKey"); + assertDelivered("serverKey"); } @Test void testProducerWithTheServerKeyAcceptingUnknownHosts() throws Exception { - assertDelivered("direct:serverKeyAcceptingUnknownHosts"); + assertDelivered("serverKeyAcceptingUnknownHosts"); } @Test void testProducerWithAnotherKey() throws Exception { - assertRejected("direct:otherKey"); + assertRejected("otherKey"); } @Test void testProducerWithAnotherKeyAcceptingUnknownHosts() throws Exception { - assertRejected("direct:otherKeyAcceptingUnknownHosts"); + assertRejected("otherKeyAcceptingUnknownHosts"); } - private void assertDelivered(String endpoint) throws Exception { + @Test + void testProducerWithAnAliasAndTheServerKey() throws Exception { + assertDelivered("aliasServerKey"); + } + + @Test + void testProducerWithAnAliasAndTheServerKeyAcceptingUnknownHosts() throws Exception { + assertDelivered("aliasServerKeyAcceptingUnknownHosts"); + } + + @Test + void testProducerWithAnAliasAndAnotherKey() throws Exception { + assertRejected("aliasOtherKey"); + } + + @Test + void testProducerWithAnAliasAndAnotherKeyAcceptingUnknownHosts() throws Exception { + assertRejected("aliasOtherKeyAcceptingUnknownHosts"); + } + + /* + * Each route uses its own user name, which the test server records when it receives credentials. + */ + private void assertDelivered(String route) throws Exception { MockEndpoint mock = getMockEndpoint("mock:result"); mock.expectedMessageCount(1); mock.expectedBodiesReceived("test"); getMockEndpoint("mock:error").expectedMessageCount(0); - template.sendBody(endpoint, "test"); + template.sendBody("direct:" + route, "test"); MockEndpoint.assertIsSatisfied(context); + assertTrue(authenticatedUsers.contains(route), "the server should receive the credentials"); } - private void assertRejected(String endpoint) throws Exception { + private void assertRejected(String route) throws Exception { getMockEndpoint("mock:result").expectedMessageCount(0); getMockEndpoint("mock:error").expectedMessageCount(1); - template.sendBody(endpoint, "test"); + template.sendBody("direct:" + route, "test"); MockEndpoint.assertIsSatisfied(context); + assertFalse(authenticatedUsers.contains(route), "the server should not receive any credentials"); } private boolean isIPv6LoopbackAvailable() { @@ -106,27 +138,28 @@ protected RouteBuilder createRouteBuilder() { public void configure() { onException(Exception.class).handled(true).to("mock:error"); - from("direct:serverKey") - .to(ipv6Endpoint(knownHostsServerKey, true)) - .to("mock:result"); - from("direct:serverKeyAcceptingUnknownHosts") - .to(ipv6Endpoint(knownHostsServerKey, false)) - .to("mock:result"); - from("direct:otherKey") - .to(ipv6Endpoint(knownHostsOtherKey, true)) - .to("mock:result"); - from("direct:otherKeyAcceptingUnknownHosts") - .to(ipv6Endpoint(knownHostsOtherKey, false)) - .to("mock:result"); + route("serverKey", "::1", knownHostsServerKey, true); + route("serverKeyAcceptingUnknownHosts", "::1", knownHostsServerKey, false); + route("otherKey", "::1", knownHostsOtherKey, true); + route("otherKeyAcceptingUnknownHosts", "::1", knownHostsOtherKey, false); + route("aliasServerKey", ALIAS, knownHostsServerKey, true); + route("aliasServerKeyAcceptingUnknownHosts", ALIAS, knownHostsServerKey, false); + route("aliasOtherKey", ALIAS, knownHostsOtherKey, true); + route("aliasOtherKeyAcceptingUnknownHosts", ALIAS, knownHostsOtherKey, false); } - }; - } - private SshEndpoint ipv6Endpoint(String knownHosts, boolean failOnUnknownHost) { - SshEndpoint endpoint = context.getEndpoint("ssh://smx:smx@localhost:" + port + "?timeout=3000&knownHostsResource=" - + knownHosts + "&failOnUnknownHost=" + failOnUnknownHost, - SshEndpoint.class); - endpoint.setHost("::1"); - return endpoint; + private void route(String name, String host, String knownHosts, boolean failOnUnknownHost) { + SshEndpoint endpoint = context.getEndpoint("ssh://" + name + ":secret@localhost:" + port + + "?timeout=3000&knownHostsResource=" + knownHosts + + "&failOnUnknownHost=" + failOnUnknownHost, + SshEndpoint.class); + endpoint.setHost(host); + // the host configuration of the client, as ~/.ssh/config would give it: Host review-ipv6, HostName ::1 + endpoint.getConfiguration().setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver( + (alias, aliasPort, localAddress, username, proxyJump, attributes) -> ALIAS.equals(alias) + ? new HostConfigEntry(alias, "::1", aliasPort, username) : null)); + from("direct:" + name).to(endpoint).to("mock:result"); + } + }; } } diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java index 73aef6e19fe1f..e29529e7a073c 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java @@ -21,14 +21,20 @@ import org.apache.camel.builder.RouteBuilder; import org.apache.camel.component.mock.MockEndpoint; +import org.apache.sshd.client.ClientBuilder; +import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + public class SshComponentKnownHostTest extends SshComponentTestSupport { private String knownHostsValid; private String knownHostsOtherHost; private String knownHostsSeveralKeys; + private String knownHostsOtherKey; @Override public void doPreSetup() throws Exception { @@ -42,6 +48,7 @@ public void doPreSetup() throws Exception { String otherKey = PublicKeyEntry.toString(generator.generateKeyPair().getPublic()); knownHostsSeveralKeys = writeKnownHosts("known_hosts_several_keys", "[localhost]:" + port + " " + otherKey, serverEntry); + knownHostsOtherKey = writeKnownHosts("known_hosts_other_key", "[localhost]:" + port + " " + otherKey); } @Test @@ -110,6 +117,29 @@ void testProducerWithSeveralKeysForTheServer() throws Exception { MockEndpoint.assertIsSatisfied(context); } + @Test + void testProducerWithAnAliasInTheHostConfiguration() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:password"); + mock.expectedMessageCount(1); + mock.expectedBodiesReceived("test"); + + template.sendBody("direct:aliasServerKey", "test"); + + MockEndpoint.assertIsSatisfied(context); + assertTrue(authenticatedUsers.contains("aliasServerKey")); + } + + @Test + void testProducerWithAnAliasInTheHostConfigurationAndAnotherKey() throws Exception { + getMockEndpoint("mock:password").expectedMessageCount(0); + getMockEndpoint("mock:error").expectedMessageCount(1); + + template.sendBody("direct:aliasOtherKey", "test"); + + MockEndpoint.assertIsSatisfied(context); + assertFalse(authenticatedUsers.contains("aliasOtherKey")); + } + @Test public void testPollingConsumerWithValidKnownHostFile() throws Exception { MockEndpoint mock = getMockEndpoint("mock:result"); @@ -167,6 +197,12 @@ public void configure() { + "?timeout=3000&knownHostsResource=" + knownHostsOtherHost + "&failOnUnknownHost=true") .to("mock:password"); + // the host configuration of the client, as ~/.ssh/config would give it: Host db-alias, HostName localhost + from("direct:aliasServerKey").to(aliasEndpoint("aliasServerKey", knownHostsValid, true)) + .to("mock:password"); + from("direct:aliasOtherKey").to(aliasEndpoint("aliasOtherKey", knownHostsOtherKey, false)) + .to("mock:password"); + from("direct:sshSeveralKeys") .to("ssh://smx:smx@localhost:" + port + "?timeout=3000&knownHostsResource=" + knownHostsSeveralKeys + "&failOnUnknownHost=true") @@ -182,4 +218,15 @@ public void configure() { } }; } + + private SshEndpoint aliasEndpoint(String user, String knownHosts, boolean failOnUnknownHost) { + SshEndpoint endpoint = context.getEndpoint("ssh://" + user + ":secret@db-alias:" + port + + "?timeout=3000&knownHostsResource=" + knownHosts + + "&failOnUnknownHost=" + failOnUnknownHost, + SshEndpoint.class); + endpoint.getConfiguration().setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver( + (host, hostPort, localAddress, username, proxyJump, attributes) -> "db-alias".equals(host) + ? new HostConfigEntry(host, "localhost", hostPort, username) : null)); + return endpoint; + } } diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java index d360732d5637d..db4b23cfea6b9 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java @@ -22,6 +22,8 @@ import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.KeyPair; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.apache.camel.test.junit6.CamelTestSupport; import org.apache.sshd.common.config.keys.PublicKeyEntry; @@ -31,6 +33,10 @@ public class SshComponentTestSupport extends CamelTestSupport { protected SshServer sshd; protected int port; + /** + * The users the test server received credentials from, a password or a public key. + */ + protected final Set authenticatedUsers = ConcurrentHashMap.newKeySet(); @Override public void doPreSetup() throws Exception { @@ -38,8 +44,14 @@ public void doPreSetup() throws Exception { sshd.setPort(0); sshd.setKeyPairProvider(new FileKeyPairProvider(Paths.get(getHostKey()))); sshd.setCommandFactory(new TestEchoCommandFactory()); - sshd.setPasswordAuthenticator((username, password, session) -> true); - sshd.setPublickeyAuthenticator((username, key, session) -> true); + sshd.setPasswordAuthenticator((username, password, session) -> { + authenticatedUsers.add(username); + return true; + }); + sshd.setPublickeyAuthenticator((username, key, session) -> { + authenticatedUsers.add(username); + return true; + }); sshd.start(); port = sshd.getPort(); } From 593f6704a68faa2dd5777ed0d957bf939a0b30ee Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Thu, 15 Oct 2026 15:35:34 +0200 Subject: [PATCH 6/7] CAMEL-25644: camel-ssh - verify against the host configuration of the connection The verifier resolved the configured host a second time, and fell back to the configured alias when that lookup failed, so a resolver that answered for the connection but not for the verifier made the verifier look up another host than the one connected to. The producer and the consumer now resolve the host configuration once, as SshClient.connect(username, host, port) does, connect with that entry and put it in the connection context. The verifier reads it from the session and uses its HostName as written, without resolving again; a host reached through a jump host uses the TARGET_SERVER that SSHD keeps as written, and the jump host its own address. A host configuration that cannot resolve the host now fails the connection before any server is contacted. The constructor that took the configured host is removed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../ssh/ResourceBasedSSHKeyVerifier.java | 69 ++++++--------- .../camel/component/ssh/SshConsumer.java | 2 +- .../apache/camel/component/ssh/SshHelper.java | 29 +++++- .../camel/component/ssh/SshProducer.java | 2 +- .../ssh/ResourceBasedSSHKeyVerifierTest.java | 88 +++++++++---------- .../ssh/SshComponentKnownHostIPv6Test.java | 26 +++++- .../ssh/SshComponentKnownHostTest.java | 38 ++++++++ 7 files changed, 161 insertions(+), 93 deletions(-) diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java index e8b83930bbb74..73bd032342bb1 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java @@ -33,11 +33,11 @@ import org.apache.camel.CamelContext; import org.apache.camel.support.ResourceHelper; -import org.apache.sshd.client.ClientFactoryManager; import org.apache.sshd.client.config.hosts.HostConfigEntry; -import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.client.session.ClientSessionCreator; +import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; @@ -68,12 +68,21 @@ public class ResourceBasedSSHKeyVerifier implements ServerKeyVerifier { private static final String HASH_ALGORITHM = "HmacSHA1"; private static final int HASH_LENGTH = 20; + /** + * The host configuration that the client connected with, which the ssh producer and consumer put in the connection + * context of the session: the entries are matched against its {@code HostName} as it is written. Without it, the + * address the client connected to is used, which writes an IPv6 address such as {@code ::1} in its full form. + * + * @since 4.24 + */ + public static final AttributeRepository.AttributeKey HOST_CONFIG_ENTRY + = new AttributeRepository.AttributeKey<>(); + protected final Logger log = LoggerFactory.getLogger(getClass()); private CamelContext camelContext; private boolean failOnUnknownHost; private String knownHostsResource; - private String host; public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource) { this(camelContext, knownHostsResource, false); @@ -81,22 +90,9 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource, boolean failOnUnknownHost) { - this(camelContext, knownHostsResource, failOnUnknownHost, null); - } - - /** - * @param host the host name or address configured on the endpoint. The entries are matched against the - * {@code HostName} that the host configuration of the client (by default {@code ~/.ssh/config}) gives - * for it, or else against the configured host, as it is written: an IPv6 address such as {@code ::1} is - * not expanded. When null, the address the client connected to is used, which writes an IPv6 address in - * its full form. - */ - public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsResource, - boolean failOnUnknownHost, String host) { this.camelContext = camelContext; this.knownHostsResource = knownHostsResource; this.failOnUnknownHost = failOnUnknownHost; - this.host = host; } @Override @@ -174,15 +170,27 @@ private boolean isSameKey(KnownHostsEntry entry, PublicKey serverKey) { } /* - * The name OpenSSH looks the server up by: the host name or address the client connected to, never resolved through - * DNS, with the port the client connected to, written host for port 22 and [host]:port for another port. + * The name OpenSSH looks the server up by: the host name or address the client connected to, as the host + * configuration writes it and never resolved through DNS, with the port the client connected to, written host for + * port 22 and [host]:port for another port. The connected address cannot give the host as it is written, as it + * writes an IPv6 address such as ::1 in its full form. */ - private String toKnownHostsName(ClientSession session, SocketAddress remoteAddress) { + private static String toKnownHostsName(ClientSession session, SocketAddress remoteAddress) { if (!(remoteAddress instanceof InetSocketAddress) && !(remoteAddress instanceof SshdSocketAddress)) { return null; } SshdSocketAddress address = SshdSocketAddress.toSshdSocketAddress(remoteAddress); - String name = host != null ? effectiveHostName(session, address.getPort()) : address.getHostName(); + String name = address.getHostName(); + SshdSocketAddress target = session != null ? session.getAttribute(ClientSessionCreator.TARGET_SERVER) : null; + AttributeRepository context = session != null ? session.getConnectionContext() : null; + HostConfigEntry hostConfig = context != null ? context.getAttribute(HOST_CONFIG_ENTRY) : null; + if (target != null) { + // a host reached through a jump host, whose HostName SSHD keeps as it is written + name = target.getHostName(); + } else if (hostConfig != null && (hostConfig.getProxyJump() == null || hostConfig.getProxyJump().isEmpty())) { + // the host the client connected to directly, with the host configuration it connected with + name = hostConfig.getHostName(); + } if (name.length() > 1 && name.charAt(0) == '[' && name.charAt(name.length() - 1) == ']') { // an IPv6 address in brackets name = name.substring(1, name.length() - 1); @@ -191,27 +199,6 @@ private String toKnownHostsName(ClientSession session, SocketAddress remoteAddre return address.getPort() == DEFAULT_PORT ? name : "[" + name + "]:" + address.getPort(); } - /* - * The host name or address the client connected to, as it is written: the HostName that the host config resolver - * of the client (by default ~/.ssh/config) gives for the configured host, or else the configured host. The connected - * address cannot be used for this, as it writes an IPv6 address such as ::1 in its full form. - */ - private String effectiveHostName(ClientSession session, int port) { - ClientFactoryManager client = session != null ? session.getFactoryManager() : null; - HostConfigEntryResolver resolver = client != null ? client.getHostConfigEntryResolver() : null; - if (resolver != null) { - try { - HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, null, session.getUsername(), null, null); - if (entry != null && entry.getHostName() != null && !entry.getHostName().isEmpty()) { - return entry.getHostName(); - } - } catch (IOException | RuntimeException e) { - log.warn("Could not resolve the host {} with the host configuration of the client", host, e); - } - } - return host; - } - /* * Matches a pattern where * stands for any sequence of characters and ? for any single character. */ diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java index df136834b5a29..b59c26712761a 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConsumer.java @@ -61,7 +61,7 @@ protected int poll() throws Exception { if (knownHostResource != null) { client.setServerKeyVerifier(new ResourceBasedSSHKeyVerifier( exchange.getContext(), knownHostResource, - endpoint.isFailOnUnknownHost(), endpoint.getHost())); + endpoint.isFailOnUnknownHost())); } SshResult result = SshHelper.sendExecCommand(exchange.getIn().getHeaders(), command, endpoint, client); diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java index b0703f1f30bdc..c26a4bc93fe58 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java @@ -18,6 +18,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; @@ -33,12 +34,16 @@ import org.apache.sshd.client.SshClient; import org.apache.sshd.client.channel.ClientChannel; import org.apache.sshd.client.channel.ClientChannelEvent; +import org.apache.sshd.client.config.hosts.HostConfigEntry; +import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.future.AuthFuture; import org.apache.sshd.client.future.ConnectFuture; import org.apache.sshd.client.future.OpenFuture; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.common.channel.Channel; import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.apache.sshd.common.util.net.SshdSocketAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,6 +54,24 @@ public final class SshHelper { private SshHelper() { } + /* + * The host configuration for the host and port, resolved as SshClient.connect(username, host, port) resolves it: the + * entry that the host config resolver of the client (by default ~/.ssh/config) gives, or else the host itself. + */ + static HostConfigEntry resolveHostConfig(SshClient client, String username, String host, int port) + throws IOException { + HostConfigEntryResolver resolver = client.getHostConfigEntryResolver(); + HostConfigEntry entry + = resolver != null ? resolver.resolveEffectiveHost(host, port, null, username, null, null) : null; + if (entry == null) { + int effectivePort = port > 0 ? port : SshConfiguration.DEFAULT_SSH_PORT; + entry = SshdSocketAddress.isIPv6Address(host) + ? new HostConfigEntry("", host, effectivePort, username, null) + : new HostConfigEntry(host, host, effectivePort, username, null); + } + return entry; + } + public static SshResult sendExecCommand(Map headers, String command, SshEndpoint endpoint, SshClient client) throws Exception { SshConfiguration configuration = endpoint.getConfiguration(); @@ -63,7 +86,11 @@ public static SshResult sendExecCommand(Map headers, String comm userName = (String) headers.get(SshConstants.USERNAME_HEADER); } - ConnectFuture connectFuture = client.connect(userName, configuration.getHost(), configuration.getPort()); + // resolve the host configuration once, and connect with it: the host key is verified against the host that this + // connection goes to, as the host configuration writes it + HostConfigEntry hostConfig = resolveHostConfig(client, userName, configuration.getHost(), configuration.getPort()); + ConnectFuture connectFuture = client.connect(hostConfig, + AttributeRepository.ofKeyValuePair(ResourceBasedSSHKeyVerifier.HOST_CONFIG_ENTRY, hostConfig), null); // wait getTimeout milliseconds for connect operation to complete connectFuture.await(configuration.getTimeout()); diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java index 6ffd0db5e90ec..850bb794d2e85 100644 --- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java +++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshProducer.java @@ -68,7 +68,7 @@ public void process(Exchange exchange) throws Exception { if (knownHostResource != null) { client.setServerKeyVerifier(new ResourceBasedSSHKeyVerifier( exchange.getContext(), knownHostResource, - endpoint.isFailOnUnknownHost(), endpoint.getHost())); + endpoint.isFailOnUnknownHost())); } SshResult result = SshHelper.sendExecCommand(headers, command, endpoint, client); diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java index ed46c10f95175..8bc5f317c6384 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java @@ -36,13 +36,14 @@ import org.apache.camel.CamelContext; import org.apache.camel.impl.DefaultCamelContext; -import org.apache.sshd.client.SshClient; import org.apache.sshd.client.config.hosts.HostConfigEntry; -import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.client.session.ClientSessionCreator; +import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.util.io.IoUtils; +import org.apache.sshd.common.util.net.SshdSocketAddress; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -253,40 +254,44 @@ void looksUpAnAddressAsConfigured() throws Exception { } @Test - void looksUpAnIpv6AddressAsConfiguredOnTheEndpoint() throws Exception { + void looksUpTheHostAsTheHostConfigurationOfTheConnectionWritesIt() throws Exception { // the address the client connected to writes ::1 as 0:0:0:0:0:0:0:1 - InetSocketAddress server = new InetSocketAddress("::1", 2222); + InetSocketAddress ipv6 = new InetSocketAddress("::1", 2222); String knownHosts = entry("[::1]:2222", serverKey); - for (String host : new String[] { "::1", "[::1]" }) { + for (String hostName : new String[] { "::1", "[::1]" }) { + // as an endpoint configured with ::1, or Host review-ipv6 / HostName ::1 in ~/.ssh/config, connects + ClientSession session = session(new HostConfigEntry("review-ipv6", hostName, 2222, "smx"), null); for (boolean failOnUnknownHost : new boolean[] { true, false }) { - assertTrue(verify(failOnUnknownHost, host, server, serverKey, knownHosts), host); - assertFalse(verify(failOnUnknownHost, host, server, otherKey, knownHosts), host); + assertTrue(verify(failOnUnknownHost, session, ipv6, serverKey, knownHosts), hostName); + assertFalse(verify(failOnUnknownHost, session, ipv6, otherKey, knownHosts), hostName); } } + // Host db / HostName db.example.com in ~/.ssh/config + ClientSession db = session(new HostConfigEntry("db", "db.example.com", 22, "smx"), null); + InetSocketAddress dbAddress + = new InetSocketAddress(InetAddress.getByAddress("db.example.com", new byte[] { 10, 0, 0, 1 }), 22); + assertTrue(verify(true, db, dbAddress, serverKey, entry("db.example.com", serverKey))); + assertFalse(verify(true, db, dbAddress, serverKey, entry("db", serverKey))); + assertFalse(verify(false, db, dbAddress, otherKey, entry("db.example.com", serverKey))); + // without the host configuration of the connection, the connected address is used as it writes the host + assertFalse(verify(true, session(null, null), ipv6, serverKey, knownHosts)); } @Test - void looksUpTheHostNameThatTheHostConfigurationGivesForTheConfiguredHost() throws Exception { - // as OpenSSH does with ~/.ssh/config: Host db, HostName db.example.com and Host review-ipv6, HostName ::1 - ClientSession session = sessionResolving((host, port, localAddress, username, proxyJump, attributes) -> { - if ("db".equals(host)) { - return new HostConfigEntry(host, "db.example.com", port, username); - } - return "review-ipv6".equals(host) ? new HostConfigEntry(host, "::1", port, username) : null; - }); - InetSocketAddress hostName - = new InetSocketAddress(InetAddress.getByAddress("db.example.com", new byte[] { 10, 0, 0, 1 }), 22); - assertTrue(verify(true, "db", session, hostName, serverKey, entry("db.example.com", serverKey))); - assertFalse(verify(true, "db", session, hostName, serverKey, entry("db", serverKey))); - assertFalse(verify(false, "db", session, hostName, otherKey, entry("db.example.com", serverKey))); - // the address the client connected to writes ::1 as 0:0:0:0:0:0:0:1 + void looksUpAHostReachedThroughAJumpHostAsItIsWritten() throws Exception { + HostConfigEntry viaJump = new HostConfigEntry("review-ipv6", "::1", 2222, "smx", "jump.example.com"); + // the target, which SSHD connects to through the jump host and keeps as it is written + ClientSession target = session(viaJump, new SshdSocketAddress("::1", 2222)); InetSocketAddress ipv6 = new InetSocketAddress("::1", 2222); - for (boolean failOnUnknownHost : new boolean[] { true, false }) { - assertTrue(verify(failOnUnknownHost, "review-ipv6", session, ipv6, serverKey, entry("[::1]:2222", serverKey))); - assertFalse(verify(failOnUnknownHost, "review-ipv6", session, ipv6, otherKey, entry("[::1]:2222", serverKey))); - } - // a host without an entry in the host configuration is looked up as it is configured - assertTrue(verify(true, "::1", session, ipv6, serverKey, entry("[::1]:2222", serverKey))); + assertTrue(verify(true, target, ipv6, serverKey, entry("[::1]:2222", serverKey))); + assertFalse(verify(false, target, ipv6, otherKey, entry("[::1]:2222", serverKey))); + // the jump host itself is looked up by its own address, not by the name of the target + ClientSession jump = session(viaJump, null); + InetSocketAddress jumpAddress + = new InetSocketAddress(InetAddress.getByAddress("jump.example.com", new byte[] { 10, 0, 0, 2 }), 22); + String knownHosts = entry("jump.example.com", otherKey) + entry("[::1]:2222", serverKey); + assertTrue(verify(true, jump, jumpAddress, otherKey, knownHosts)); + assertFalse(verify(true, jump, jumpAddress, serverKey, knownHosts)); } @Test @@ -318,36 +323,29 @@ private boolean verify( } private boolean verify( - boolean failOnUnknownHost, String configuredHost, InetSocketAddress server, PublicKey presented, + boolean failOnUnknownHost, ClientSession session, InetSocketAddress server, PublicKey presented, String knownHosts) throws Exception { - return verify(failOnUnknownHost, configuredHost, null, server, presented, knownHosts); - } - - private boolean verify( - boolean failOnUnknownHost, String configuredHost, ClientSession session, InetSocketAddress server, - PublicKey presented, String knownHosts) - throws Exception { Path file = Files.createTempFile(dir, "known_hosts", ""); Files.writeString(file, knownHosts, StandardCharsets.UTF_8); - ResourceBasedSSHKeyVerifier verifier - = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost, configuredHost); + ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost); return verifier.verifyServerKey(session, server, presented); } /* - * A session of a client with the given host configuration, which is all the verifier asks the session for. + * A session connected with the given host configuration, as the ssh producer and consumer connect, and reached + * through a jump host when a target server is given: all that the verifier asks the session for. */ - private static ClientSession sessionResolving(HostConfigEntryResolver resolver) { - SshClient client = SshClient.setUpDefaultClient(); - client.setHostConfigEntryResolver(resolver); + private static ClientSession session(HostConfigEntry hostConfig, SshdSocketAddress targetServer) { + AttributeRepository connectionContext = hostConfig != null + ? AttributeRepository.ofKeyValuePair(ResourceBasedSSHKeyVerifier.HOST_CONFIG_ENTRY, hostConfig) : null; return (ClientSession) Proxy.newProxyInstance(ClientSession.class.getClassLoader(), new Class[] { ClientSession.class }, (proxy, method, args) -> { switch (method.getName()) { - case "getFactoryManager": - return client; - case "getUsername": - return "smx"; + case "getConnectionContext": + return connectionContext; + case "getAttribute": + return args[0] == ClientSessionCreator.TARGET_SERVER ? targetServer : null; default: Class type = method.getReturnType(); // the default value of a primitive type, null otherwise diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java index bf1e2a648efd5..45d390cec790e 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java @@ -21,11 +21,13 @@ import java.net.Socket; import java.security.KeyPairGenerator; import java.security.spec.ECGenParameterSpec; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.camel.builder.RouteBuilder; import org.apache.camel.component.mock.MockEndpoint; import org.apache.sshd.client.ClientBuilder; import org.apache.sshd.client.config.hosts.HostConfigEntry; +import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.junit.jupiter.api.Test; @@ -131,6 +133,24 @@ private boolean isIPv6LoopbackAvailable() { } } + /* + * The host configuration of the client, as ~/.ssh/config would give it: Host review-ipv6, HostName ::1. It answers + * only once, so the host key must be checked against the host that the connection was resolved to, without asking + * the host configuration again. + */ + private static HostConfigEntryResolver resolvingOnce() { + AtomicBoolean resolved = new AtomicBoolean(); + return (host, hostPort, localAddress, username, proxyJump, attributes) -> { + if (!ALIAS.equals(host)) { + return null; + } + if (resolved.getAndSet(true)) { + throw new IOException("The host configuration answers only once"); + } + return new HostConfigEntry(host, "::1", hostPort, username); + }; + } + @Override protected RouteBuilder createRouteBuilder() { return new RouteBuilder() { @@ -154,10 +174,8 @@ private void route(String name, String host, String knownHosts, boolean failOnUn + "&failOnUnknownHost=" + failOnUnknownHost, SshEndpoint.class); endpoint.setHost(host); - // the host configuration of the client, as ~/.ssh/config would give it: Host review-ipv6, HostName ::1 - endpoint.getConfiguration().setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver( - (alias, aliasPort, localAddress, username, proxyJump, attributes) -> ALIAS.equals(alias) - ? new HostConfigEntry(alias, "::1", aliasPort, username) : null)); + endpoint.getConfiguration() + .setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver(resolvingOnce())); from("direct:" + name).to(endpoint).to("mock:result"); } }; diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java index e29529e7a073c..977e28e276053 100644 --- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java +++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java @@ -16,6 +16,7 @@ */ package org.apache.camel.component.ssh; +import java.io.IOException; import java.security.KeyPairGenerator; import java.security.spec.ECGenParameterSpec; @@ -140,6 +141,26 @@ void testProducerWithAnAliasInTheHostConfigurationAndAnotherKey() throws Excepti assertFalse(authenticatedUsers.contains("aliasOtherKey")); } + @Test + void testProducerWithAFailingHostConfiguration() throws Exception { + assertNotConnected("brokenAlias"); + } + + @Test + void testProducerWithAFailingHostConfigurationAcceptingUnknownHosts() throws Exception { + assertNotConnected("brokenAliasAcceptingUnknownHosts"); + } + + private void assertNotConnected(String route) throws Exception { + getMockEndpoint("mock:password").expectedMessageCount(0); + getMockEndpoint("mock:error").expectedMessageCount(1); + + template.sendBody("direct:" + route, "test"); + + MockEndpoint.assertIsSatisfied(context); + assertFalse(authenticatedUsers.contains(route)); + } + @Test public void testPollingConsumerWithValidKnownHostFile() throws Exception { MockEndpoint mock = getMockEndpoint("mock:result"); @@ -203,6 +224,11 @@ public void configure() { from("direct:aliasOtherKey").to(aliasEndpoint("aliasOtherKey", knownHostsOtherKey, false)) .to("mock:password"); + // a host configuration that cannot resolve the host: the producer must not connect anywhere + from("direct:brokenAlias").to(brokenAliasEndpoint("brokenAlias", true)).to("mock:password"); + from("direct:brokenAliasAcceptingUnknownHosts") + .to(brokenAliasEndpoint("brokenAliasAcceptingUnknownHosts", false)).to("mock:password"); + from("direct:sshSeveralKeys") .to("ssh://smx:smx@localhost:" + port + "?timeout=3000&knownHostsResource=" + knownHostsSeveralKeys + "&failOnUnknownHost=true") @@ -229,4 +255,16 @@ private SshEndpoint aliasEndpoint(String user, String knownHosts, boolean failOn ? new HostConfigEntry(host, "localhost", hostPort, username) : null)); return endpoint; } + + private SshEndpoint brokenAliasEndpoint(String user, boolean failOnUnknownHost) { + SshEndpoint endpoint = context.getEndpoint("ssh://" + user + ":secret@broken-alias:" + port + + "?timeout=3000&knownHostsResource=" + knownHostsValid + + "&failOnUnknownHost=" + failOnUnknownHost, + SshEndpoint.class); + endpoint.getConfiguration().setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver( + (host, hostPort, localAddress, username, proxyJump, attributes) -> { + throw new IOException("Cannot read the host configuration"); + })); + return endpoint; + } } From e182197b4d4cdec19b5a6d2837365f39e01fca27 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Sun, 18 Oct 2026 12:34:16 +0200 Subject: [PATCH 7/7] CAMEL-25644: move the 4.22 upgrade note to 4.22.3 camel-4.22.2 has been cut, so the camel-4.22.x backport of this change ships in 4.22.3. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- .../pages/camel-4x-upgrade-guide-4_22.adoc | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 026f55893688e..19339087b9c50 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -11,6 +11,37 @@ Note that manual migration is still required. See the xref:camel-upgrade-recipes-tool.adoc[documentation] page for details. ==== +== Upgrading from 4.22.2 to 4.22.3 + +=== camel-ssh - known_hosts entries are matched as in OpenSSH + +When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH +matches them, against the host name or address the client connects to, written `host` for port 22 and +`[host]:port` for another port: + +* A host pattern must match that name as a whole. An entry for another host whose name or address +contains the configured one is no longer used. +* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry +such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. +* The host is used as it is written: the host configured on the endpoint, or the `HostName` that +`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed +only under its address, an endpoint configured with an address no longer uses entries listed under the +name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as +written instead of in its expanded form. +* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated +and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are +ignored. +* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an +unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail, +whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry, +and such a line was skipped. + +As before, a server whose host has entries without a marker is rejected when it presents a key that +none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without +such an entry: `@revoked` and `@cert-authority` entries do not list a host. +Check that the `known_hosts` file has an entry for the host name or address, and the port, that each +endpoint is configured with. + == Upgrading from 4.22.1 to 4.22.2 === camel-lra - only an LRA hosted by the configured coordinator is joined @@ -167,35 +198,6 @@ sent to the partition with the same id in the target Event Hub. To keep doing so Event Hubs that have the same partitions, remove the `CamelAzureEventHubsReceivedPartitionId` exchange property before the producer. -=== camel-ssh - known_hosts entries are matched as in OpenSSH - -When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH -matches them, against the host name or address the client connects to, written `host` for port 22 and -`[host]:port` for another port: - -* A host pattern must match that name as a whole. An entry for another host whose name or address -contains the configured one is no longer used. -* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry -such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`. -* The host is used as it is written: the host configured on the endpoint, or the `HostName` that -`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed -only under its address, an endpoint configured with an address no longer uses entries listed under the -name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as -written instead of in its expanded form. -* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated -and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are -ignored. -* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an -unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail, -whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry, -and such a line was skipped. - -As before, a server whose host has entries without a marker is rejected when it presents a key that -none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without -such an entry: `@revoked` and `@cert-authority` entries do not list a host. -Check that the `known_hosts` file has an entry for the host name or address, and the port, that each -endpoint is configured with. - === camel-xslt and camel-xslt-saxon - implicit DOM sources When a non-`Source` input is implicitly converted to a `DOMSource` pointing at its owner document's