ldapjs / node-ldapjs

LDAP Client and Server API for node.js
http://ldapjs.org
MIT License
1.61k stars 440 forks source link

TLS Error: Expected 0x02: got 0x54 #853

Closed d3xt3r01 closed 1 year ago

d3xt3r01 commented 1 year ago

So it all started when trying to make rocketchat use ldap. It kept giving timeouts for some reason that I couldn't understand. Further digging pointed me to the ldapjs module.

My openldap server is behind a tcp router in traefik with TLS ( NOT STARTTLS!!! ). I can connect to it just fine from a shell

$ ldapwhoami -H 'ldaps://openldap.local.lan:1636' -D 'cn=admin,dc=local,dc=lan' -W
Enter LDAP Password:
dn:cn=admin,dc=local,dc=lan

But using the small snippet here throws me the error I see in rocketchat...

const ldap = require('ldapjs');

tlsOptions = { 'rejectUnauthorized': false }

const client = ldap.createClient({ url: ['ldaps://openldap.local.lan:1636'], timeout: 1000, connectTimeout: 1000, idleTimeout: 1000, tlsOptions: tlsOptions });

client.bind('cn=admin,dc=local,dc=lan', '....', function (err) {
  client.unbind();
  console.log(err)
 });

client.on('connectError', (err) => {
        console.log(err);
})

and the error...

$ node t.js
TimeoutError: request timeout (client interrupt)
    at Timeout.onRequestTimeout (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/client/client.js:1277:10)
    at listOnTimeout (node:internal/timers:569:17)
    at process.processTimers (node:internal/timers:512:7) {
  lde_message: 'request timeout (client interrupt)',
  lde_dn: null
}

node:events:491
      throw er; // Unhandled 'error' event
      ^
VError: Parser error for 1__ldaps://openldap.local.lan:1636: Expected 0x02: got 0x54
    at Parser.onParseError (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/client/client.js:923:26)
    at Parser.emit (node:events:513:28)
    at Parser.write (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/messages/parser.js:131:10)
    at TLSSocket.onData (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/client/client.js:875:22)
    at TLSSocket.emit (node:events:513:28)
    at addChunk (node:internal/streams/readable:324:12)
    at readableAddChunk (node:internal/streams/readable:297:9)
    at Readable.push (node:internal/streams/readable:234:10)
    at TLSWrap.onStreamRead (node:internal/stream_base_commons:190:23)
Emitted 'error' event on Client instance at:
    at Parser.onParseError (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/client/client.js:923:12)
    at Parser.emit (node:events:513:28)
    [... lines matching original stack trace ...]
    at TLSWrap.onStreamRead (node:internal/stream_base_commons:190:23) {
  jse_shortmsg: 'Parser error for 1__ldaps://openldap.local.lan:1636',
  jse_cause: Error: Expected 0x02: got 0x54
      at BerReader.readTag (/home/dexter/public_html/work/personal/npm/node_modules/@ldapjs/asn1/lib/ber/reader.js:422:13)
      at BerReader.readInt (/home/dexter/public_html/work/personal/npm/node_modules/@ldapjs/asn1/lib/ber/reader.js:179:28)
      at parseToMessage (/home/dexter/public_html/work/personal/npm/node_modules/@ldapjs/messages/lib/parse-to-message.js:57:25)
      at LdapMessage.parse (/home/dexter/public_html/work/personal/npm/node_modules/@ldapjs/messages/lib/ldap-message.js:262:41)
      at Parser.write (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/messages/parser.js:117:38)
      at TLSSocket.onData (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/client/client.js:875:22)
      at TLSSocket.emit (node:events:513:28)
      at addChunk (node:internal/streams/readable:324:12)
      at readableAddChunk (node:internal/streams/readable:297:9)
      at Readable.push (node:internal/streams/readable:234:10),
  jse_info: {},
  cause: [Function: ve_cause]
}

Node.js v18.14.2

If this is an issue, I assume it's the same as in rocketchat... any pointers given would be useful to open up an issue with rocketchat.

jsumners commented 1 year ago

You have neglected to provide the version of ldapjs you are using. However, what the stacktrace is showing is that an LDAP message is malformed (or not being processed correctly). I assume the message id is trying to be read, but has instead found a different field.

Without a reproduction, the minimum amount of information we would need would be any errors reported by some of the error events listed at https://github.com/ldapjs/node-ldapjs/blob/9613308c331ec944029cd01b9039c593f1cdd214/docs/client.md#client-events

Ideally, you would provide a Wireshark capture of the issue. It would be highly recommended that you create a test account with trash credentials to use during this capture.

  1. Start Wireshark
  2. Set a capture filter of port <ldap_port> (e.g. port 636)
  3. Set the capture interface to the one that will be used to communicate with the LDAP server
  4. Start the capture
  5. Run the below tls-capture.js script (with env vars set as required)
  6. Stop the Wireshark capture
  7. Save the capture to a file
  8. Create a zip file of the capture file and the generated tls.log file
  9. Use the public key below to encrypt the zip file
    $ openssl rsautl -encrypt -inkey public.key -pubin -in files.zip -out files.rsa.zip
  10. Attach the zip file to a comment on this issue
tls-capture.js ```js 'use strict' const SSL_LOG_FILE = process.env.SSLKEYLOGFILE ?? '/tmp/tls.log' const HOST = process.env.HOST ?? '127.0.0.1' const PORT = process.env.PORT ?? '1636' const BIND_DN = process.env.BIND_DN ?? 'Administrator' const BIND_PW = process.env.BIND_PW ?? 'Test123456' const fs = require('fs') const logFile = fs.createWriteStream(SSL_LOG_FILE, {flags: 'a'}) let socket const tls = require('tls') const connect = tls.connect tls.connect = (...args) => { socket = connect.apply(tls, args) socket.on('keylog', line => { logFile.write(line) }) return socket } const ldapjs = require('ldapjs') const client = ldapjs.createClient({ url: `ldaps://${HOST}:${PORT}`, timeout: 1000, connectTimeout: 1000, idleTimeout: 1000, tlsOptions: { rejectUnauthorized: false } }) client.bind(BIND_DN, BIND_PW, error => { if (error) { console.error('bind error:', error) process.exit(1) } console.log('connected') }) client.on('connectError:', error => { console.error('connectError', error) }) client.on('setupError:', error => { console.error('setupError', error) }) client.on('resultError:', error => { console.error('resultError', error) }) ```
encryption public key ``` -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy+SV35XBwKvDI+AmU6Ql k1U6JX0+N/qevqfwn4Z6GLLXrji+BiXC0dpJo36T0G7NjK+6dASUXbj/3fr5NC8h fdPD+S18NrOYufwoqDhkj4pcADvBx3Juc183KDcnf3aYE2w3dQdImVKJkfjkvEgj eMPVbgNveaLEBGztWx0ysXknqtxbghO5UMS0yvYJOggDUukxTGQhBr+A58PxPFvO 1PBHHsS9pg01/MJZ2cQuhAf5UA4A4R0yHYFlgp0EldWliHu8Y0CuORsM2kGtPl9/ LXvB5OVfKcojZPi7Glu0q9up02efFbwVaoavk5zR/LXWnKXFWQT2TkfOB1AGoaol fwIDAQAB -----END PUBLIC KEY----- ```
d3xt3r01 commented 1 year ago

Thanks for the fast reply!

bind error: TimeoutError: request timeout (client interrupt)
    at Timeout.onRequestTimeout (/home/dexter/public_html/work/personal/npm/node_modules/ldapjs/lib/client/client.js:1277:10)
    at listOnTimeout (node:internal/timers:569:17)
    at process.processTimers (node:internal/timers:512:7) {
  lde_message: 'request timeout (client interrupt)',
  lde_dn: null
}

I doubt it gets to talk to the ldap. I think it fails somewhere in the TLS part (traefik handles that part just fine for all other services, plus it works ok with a shell client). Might it be because it needs SNI?

Trying to use encrypt the output fails...

$ openssl rsautl -encrypt -inkey pk.key -pubin -in ldapjs853.zip -out ldapjs853.zip.enc
RSA operation error
140319407814464:error:0406D06E:rsa routines:RSA_padding_add_PKCS1_type_2:data too large for key size:crypto/rsa/rsa_pk1.c:124:

Attached the requested zip anyway. I don't have anything important there anyway, it's a local docker test environment in which I can reset everything fast.

ldapjs853.zip

jsumners commented 1 year ago

If you load the capture in Wireshark, attach the tls.log (https://wiki.wireshark.org/TLS#using-the-pre-master-secret), and look at the decrypted body of frame 13 you'll see:

HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Connection: close

400 Bad Request

Frame 13 is a response to frame 10, which is an appropriate BIND request:

0000   30 34 02 01 01 60 2f 02 01 03 04 18 63 6e 3d 61   04...`/.....cn=a
0010   64 6d 69 6e 2c 64 63 3d 6c 6f 63 61 6c 2c 64 63   dmin,dc=local,dc
0020   3d 6c 61 6e 80 10 <rest redacted>

So what it looks like to me is that you are attempting to talk LDAP over HTTP. That's not going to work.

d3xt3r01 commented 1 year ago

So it is because of the client not sending SNI then? The shell client handles it just fine!

$ ldapwhoami -x -D "cn=admin,dc=local,dc=lan" -H ldaps://openldap.local.lan:1636 -W
Enter LDAP Password:
dn:cn=admin,dc=local,dc=lan
d3xt3r01 commented 1 year ago

This is a capture from the ldapwhoami above.

sni.zip

In this one on frame 4 ( Client Hello ) Extension: server_name can be seen.

d3xt3r01 commented 1 year ago

More context: I'm using Traefik as a tcp proxy. Which probably requires the tcp SNI to identify the route to send it to the ldap container... Otherwise probably it yells 400 not knowing what to do. I agree it's weird that given only one service is behind, it should transmit everything there... but also, the client should know SNI which is a very well known TLS extension and used almost everywhere where I've seen TLS involved.

d3xt3r01 commented 1 year ago

I tried setting sniStrict: false for this route with no luck. In this case it might be a bug in traefik's part but that's another story. The CLI client still works.

jsumners commented 1 year ago

Sorry, but this is clearly not an issue with ldapjs. You'll need to determine how to make your proxy function correctly as a plain TCP proxy instead of an HTTP proxy.

See https://community.traefik.io/t/how-to-proxy-tcp-with-tls-for-clients-lacking-hostsni-for-use-cases-such-as-ldap-redis/15570 (🤷‍♂️ just the first result from a search).

d3xt3r01 commented 1 year ago

The issue is at another layer!

Http and ldap are layer7, TLS is below that.

It's not an http proxy, it's a TCP proxy.

SNI isn't http related, it's a TLS extension especially used to identify resources like in this case.

This can be seen in the last pcap file and I am sure the cli ldap clients don't do any http at all, they implemented sni on TLS as one should.

Another example that works just fine is authelia and any other service so far (nextcloud, postfix among others.)

This is clearly just a couple of lines somewhere to add the hostname in the server connection context when doing the TLS this can be extracted from the URI and passed on. Shouldn't affect anything otherwise.

The result is http because that's the way traefik answers to unknown TLS without sni requests.

You might not plan to implement this but this still is an issue in this library in this particular case.

On Mon, 13 Mar 2023, 00:21 James Sumners, @.***> wrote:

Closed #853 https://github.com/ldapjs/node-ldapjs/issues/853 as not planned.

— Reply to this email directly, view it on GitHub https://github.com/ldapjs/node-ldapjs/issues/853#event-8726408942, or unsubscribe https://github.com/notifications/unsubscribe-auth/AARHYG3LO54PU7ETOWBG7Z3W3ZLBFANCNFSM6AAAAAAVYEBDQI . You are receiving this because you authored the thread.Message ID: @.***>

jsumners commented 1 year ago

This is not an issue of the ldapjs library. This library uses the standard tls.connect method from the Node.js core framework. We do not do anything special in regard to SNI or not.

See:

  1. https://github.com/ldapjs/node-ldapjs/blob/9613308c331ec944029cd01b9039c593f1cdd214/docs/client.md?plain=1#L42
  2. https://github.com/ldapjs/node-ldapjs/blob/9613308c331ec944029cd01b9039c593f1cdd214/lib/client/client.js#L835
  3. https://nodejs.org/dist/latest-v18.x/docs/api/tls.html#tlsconnectport-host-options-callback
d3xt3r01 commented 1 year ago

Unlike the https API, tls.connect() does not enable the SNI (Server Name Indication) extension by default, which may cause some servers to return an incorrect certificate or reject the connection altogether. To enable SNI, set the servername option in addition to host.

I guess it should turn it on, it doesn't do any harm.

Perfect, so all that's needed seems to be a servername: "openldap.local.lan" . I'll open a ticket with rocketchat to add this option.

MichaelUray commented 9 months ago

Why not to automatically handover the hostname from the ldaps:// URI to the tls.connect() option servername? If you use ldaps:// instead of ldap:// then you probably want to have SNI enabled.

jsumners commented 9 months ago

Because we do not know that all clients will always want to pass in that option. As noted, the tlsOptions parameter is directly passed to tls.connect.