From bb8116d21f7849bf36cc356de4d8cfec579eba64 Mon Sep 17 00:00:00 2001
From: TJ Saunders <tj@castaglia.org>
Date: Tue, 26 Oct 2021 19:10:59 -0700
Subject: [PATCH] Issue #1346: When accept connections for passive data
 transfers, and a class name has been configured for `AllowForeignAddress`,
 check the connecting client against that class.

---
 src/inet.c                                    |  90 +++++--
 .../Tests/Config/AllowForeignAddress.pm       | 255 +++++++++++++++++-
 2 files changed, 314 insertions(+), 31 deletions(-)

diff --git a/src/inet.c b/src/inet.c
index 33ce349aa3..81c1c99b1d 100644
--- a/src/inet.c
+++ b/src/inet.c
@@ -1522,9 +1522,9 @@ int pr_inet_accept_nowait(pool *p, conn_t *c) {
  */
 conn_t *pr_inet_accept(pool *p, conn_t *d, conn_t *c, int rfd, int wfd,
     unsigned char resolve) {
+  config_rec *allow_foreign_addr_config = NULL;
   conn_t *res = NULL;
-  unsigned char *foreign_addr = NULL;
-  int fd = -1, allow_foreign_address = FALSE;
+  int fd = -1;
   pr_netaddr_t na;
   socklen_t nalen;
 
@@ -1540,13 +1540,10 @@ conn_t *pr_inet_accept(pool *p, conn_t *d, conn_t *c, int rfd, int wfd,
   pr_netaddr_set_family(&na, pr_netaddr_get_family(c->remote_addr));
   nalen = pr_netaddr_get_sockaddr_len(&na);
 
+  allow_foreign_addr_config = find_config(TOPLEVEL_CONF, CONF_PARAM,
+    "AllowForeignAddress", FALSE);
   d->mode = CM_ACCEPT;
 
-  foreign_addr = get_param_ptr(TOPLEVEL_CONF, "AllowForeignAddress", FALSE);
-  if (foreign_addr != NULL) {
-    allow_foreign_address = *foreign_addr;
-  }
-
   /* A directive could enforce only IPv4 or IPv6 connections here, by
    * actually using a sockaddr argument to accept(2), and checking the
    * family of the connecting entity.
@@ -1566,28 +1563,67 @@ conn_t *pr_inet_accept(pool *p, conn_t *d, conn_t *c, int rfd, int wfd,
       break;
     }
 
-    if (allow_foreign_address == FALSE) {
-      /* If foreign addresses (i.e. IP addresses that do not match the
-       * control connection's remote IP address) are not allowed, we
-       * need to see just what our remote address IS.
-       */
-      if (getpeername(fd, pr_netaddr_get_sockaddr(&na), &nalen) < 0) {
-        /* If getpeername(2) fails, should we still allow this connection?
-         * Caution (and the AllowForeignAddress setting say "no".
+    if (allow_foreign_addr_config != NULL) {
+      int allowed;
+
+      allowed = *((int *) allow_foreign_addr_config->argv[0]);
+      if (allowed != TRUE) {
+        /* If foreign addresses (i.e. IP addresses that do not match the
+         * control connection's remote IP address) are not allowed, we
+         * need to see just what our remote address IS.
          */
-        pr_log_pri(PR_LOG_DEBUG, "rejecting passive connection; "
-          "failed to get address of remote peer: %s", strerror(errno));
-        (void) close(fd);
-        continue;
-      }
 
-      if (pr_netaddr_cmp(&na, c->remote_addr) != 0) {
-        pr_log_pri(PR_LOG_NOTICE, "SECURITY VIOLATION: Passive connection "
-          "from foreign IP address %s rejected (does not match client "
-          "IP address %s).", pr_netaddr_get_ipstr(&na),
-          pr_netaddr_get_ipstr(c->remote_addr));
-        (void) close(fd);
-        continue;
+        if (getpeername(fd, pr_netaddr_get_sockaddr(&na), &nalen) < 0) {
+          /* If getpeername(2) fails, should we still allow this connection?
+           * Caution (and the AllowForeignAddress setting) say "no".
+           */
+          pr_log_pri(PR_LOG_DEBUG, "rejecting passive connection; "
+            "failed to get address of remote peer: %s", strerror(errno));
+          (void) close(fd);
+          continue;
+        }
+
+        if (allowed == FALSE) {
+          if (pr_netaddr_cmp(&na, c->remote_addr) != 0) {
+            pr_log_pri(PR_LOG_NOTICE, "SECURITY VIOLATION: Passive connection "
+              "from foreign IP address %s rejected (does not match client "
+              "IP address %s).", pr_netaddr_get_ipstr(&na),
+              pr_netaddr_get_ipstr(c->remote_addr));
+
+            (void) close(fd);
+            d->mode = CM_ERROR;
+            d->xerrno = EACCES;
+
+            return NULL;
+          }
+
+        } else {
+          char *class_name;
+          const pr_class_t *cls;
+
+          class_name = allow_foreign_addr_config->argv[1];
+          cls = pr_class_find(class_name);
+          if (cls != NULL) {
+            if (pr_class_satisfied(p, cls, &na) != TRUE) {
+              pr_log_debug(DEBUG8, "<Class> '%s' not satisfied by foreign "
+                "address '%s'", class_name, pr_netaddr_get_ipstr(&na));
+
+              pr_log_pri(PR_LOG_NOTICE,
+                "SECURITY VIOLATION: Passive connection from foreign IP "
+                "address %s rejected (does not match <Class %s>).",
+                pr_netaddr_get_ipstr(&na), class_name);
+
+              (void) close(fd);
+              d->mode = CM_ERROR;
+              d->xerrno = EACCES;
+              return NULL;
+            }
+
+          } else {
+            pr_log_debug(DEBUG8, "<Class> '%s' not found for filtering "
+              "AllowForeignAddress", class_name);
+          }
+        }
       }
     }
 
diff --git a/tests/t/lib/ProFTPD/Tests/Config/AllowForeignAddress.pm b/tests/t/lib/ProFTPD/Tests/Config/AllowForeignAddress.pm
index cd64b5578d..0ee36cbb4e 100644
--- a/tests/t/lib/ProFTPD/Tests/Config/AllowForeignAddress.pm
+++ b/tests/t/lib/ProFTPD/Tests/Config/AllowForeignAddress.pm
@@ -26,11 +26,16 @@ my $TESTS = {
     test_class => [qw(forking)],
   },
 
-  fxp_denied_by_class => {
+  fxp_port_denied_by_class => {
     order => ++$order,
     test_class => [qw(forking)],
   },
 
+  fxp_pasv_denied_by_class_issue1346 => {
+    order => ++$order,
+    test_class => [qw(bug forking)],
+  },
+
   fxp_allowed => {
     order => ++$order,
     test_class => [qw(forking)],
@@ -41,11 +46,16 @@ my $TESTS = {
     test_class => [qw(forking)],
   },
 
-  fxp_allowed_by_class => {
+  fxp_port_allowed_by_class => {
     order => ++$order,
     test_class => [qw(forking)],
   },
 
+  fxp_pasv_allowed_by_class_issue1346 => {
+    order => ++$order,
+    test_class => [qw(bug forking)],
+  },
+
   fxp_allowed_2gb => {
     order => ++$order,
     test_class => [qw(forking)],
@@ -353,7 +363,7 @@ sub fxp_denied_eprt {
   test_cleanup($setup->{log_file}, $ex);
 }
 
-sub fxp_denied_by_class {
+sub fxp_port_denied_by_class {
   my $self = shift;
   my $tmpdir = $self->{tmpdir};
   my $setup = test_setup($tmpdir, 'config');
@@ -519,6 +529,139 @@ EOC
   test_cleanup($setup->{log_file}, $ex);
 }
 
+sub fxp_pasv_denied_by_class_issue1346 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'config');
+
+  my $class_name = 'allowed_fxp';
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+
+    AllowForeignAddress => $class_name,
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  if (open(my $fh, ">> $setup->{config_file}")) {
+    print $fh <<EOC;
+<Class $class_name>
+  From none
+</Class>
+EOC
+    unless (close($fh)) {
+      die("Can't write $setup->{config_file}: $!");
+    }
+
+  } else {
+    die("Can't open $setup->{config_file}: $!");
+  }
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port, 0, 3);
+      $client->login($setup->{user}, $setup->{passwd});
+
+      # Attemping a data transfer should fail, due to the AllowForeignAddress
+      # class restriction.
+
+      my $conn = $client->list_raw();
+      if ($conn) {
+        die("LIST succeeded unexpectedly");
+      }
+
+      my $resp_code = $client->response_code();
+      my $resp_msg = $client->response_msg();
+
+      my $expected = 425;
+      $self->assert($expected == $resp_code,
+        "Expected response code $expected, got $resp_code");
+
+      $expected = 'Unable to build data connection:';
+      $self->assert(qr/$expected/, $resp_msg,
+        "Expected response message '$expected', got '$resp_msg'");
+
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  eval {
+    if (open(my $fh, "< $setup->{log_file}")) {
+      my $ok = 0;
+
+      while (my $line = <$fh>) {
+        chomp($line);
+
+        if ($ENV{TEST_VERBOSE}) {
+          print STDERR "$line\n";
+        }
+
+        if ($line =~ /SECURITY VIOLATION: Passive connection from foreign IP address \S+ rejected \(does not match <Class \S+>\)/) {
+          $ok = 1;
+          last;
+        }
+      }
+
+      close($fh);
+
+      $self->assert($ok, "Did not see expected log messages");
+
+    } else {
+      die("Can't read $setup->{log_file}: $!");
+    }
+  };
+  if ($@) {
+    $ex = $@;
+  }
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
 sub fxp_allowed {
   my $self = shift;
   my $tmpdir = $self->{tmpdir};
@@ -781,7 +924,7 @@ sub fxp_allowed_eprt {
   test_cleanup($setup->{log_file}, $ex);
 }
 
-sub fxp_allowed_by_class {
+sub fxp_port_allowed_by_class {
   my $self = shift;
   my $tmpdir = $self->{tmpdir};
   my $setup = test_setup($tmpdir, 'config');
@@ -926,6 +1069,110 @@ EOC
   test_cleanup($setup->{log_file}, $ex);
 }
 
+sub fxp_pasv_allowed_by_class_issue1346 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'config');
+
+  my $class_name = 'allowed_fxp';
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+    TraceLog => $setup->{log_file},
+    Trace => 'class:20 inet:20',
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+
+    AllowForeignAddress => $class_name,
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  if (open(my $fh, ">> $setup->{config_file}")) {
+    print $fh <<EOC;
+<Class $class_name>
+  From 127.0.0.0/8
+</Class>
+EOC
+    unless (close($fh)) {
+      die("Can't write $setup->{config_file}: $!");
+    }
+
+  } else {
+    die("Can't open $setup->{config_file}: $!");
+  }
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port, 0, 3);
+      $client->login($setup->{user}, $setup->{passwd});
+
+      # Attemping a data transfer should succeed, due to the AllowForeignAddress
+      # class restriction.
+      my $conn = $client->list_raw();
+      unless ($conn) {
+        die("Failed to LIST: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      $conn->read($buf, 8192, 30);
+      eval { $conn->close() };
+
+      my ($resp_code, $resp_msg);
+      $resp_code = $client->response_code();
+      $resp_msg = $client->response_msg();
+
+      $self->assert_transfer_ok($resp_code, $resp_msg);
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
 sub fxp_allowed_2gb {
   my $self = shift;
   my $tmpdir = $self->{tmpdir};
