Skip to content

Add Active FTP mode support (Issue #43)#55

Merged
sparkeh9 merged 5 commits intomasterfrom
feature/active-ftp-mode
Mar 1, 2026
Merged

Add Active FTP mode support (Issue #43)#55
sparkeh9 merged 5 commits intomasterfrom
feature/active-ftp-mode

Conversation

@sparkeh9
Copy link
Copy Markdown
Owner

@sparkeh9 sparkeh9 commented Mar 1, 2026

Summary

Adds Active FTP mode (PORT) support so the library can work with servers that don't support passive mode.

Closes #43

How It Works

var config = new FtpClientConfiguration
{
    Host = "ftp.example.com",
    Username = "user",
    Password = "pass",
    DataConnectionType = FtpDataConnectionType.Active,
    ActiveExternalIp = "203.0.113.10"  // Optional: set if behind NAT
};

using var client = new FtpClient(config);
await client.LoginAsync();
var files = await client.ListAllAsync();  // Uses PORT instead of PASV

Changes

New Files

  • FtpDataConnectionType.cs — enum with AutoPassive (default, existing behavior) and Active
  • ActiveDataStream.cs — Lazy-accept wrapper stream that defers AcceptTcpClientAsync until the first read, solving the PORT/LIST sequencing deadlock
  • ActiveModeTests.cs — 2 integration tests using in-process fake FTP server

Modified Files

  • FtpCommand.cs — Added PORT and EPRT commands
  • FtpClientConfiguration.cs — Added DataConnectionType and ActiveExternalIp properties
  • FtpClient.cs — Refactored ConnectDataStreamAsync into ConnectPassiveDataStreamAsync and ConnectActiveDataStreamAsync; added FormatPortCommand helper
  • FtpControlStream.cs — Added LocalIpAddress property and WrapDataStreamAsync method

Key Design Decision: Lazy Accept

The FTP protocol requires the data command (LIST/STOR) to be sent after PORT, but the server only connects to the client's data port after receiving the data command. Since ConnectDataStreamAsync is called before the data command is sent, we use ActiveDataStream to defer the TCP accept until the first Read() call. This avoids a deadlock without changing the existing method signatures.

Backward Compatibility

  • Default behavior unchanged (AutoPassive = EPSV → PASV fallback)
  • No breaking changes

Test Results

  • All 23 tests pass (21 existing + 2 new Active mode tests)

- Add FtpDataConnectionType enum (AutoPassive, Active)
- Add PORT and EPRT to FtpCommand enum
- Add DataConnectionType and ActiveExternalIp config properties
- Refactor ConnectDataStreamAsync into Passive/Active branches
- Active mode: binds TcpListener, sends PORT, returns lazy-accept ActiveDataStream
- ActiveDataStream defers AcceptTcpClient until first Read to avoid deadlock
- Add WrapDataStreamAsync and LocalIpAddress to FtpControlStream
- Add 2 integration tests using in-process fake FTP server
- All 23 tests pass, no breaking changes
Copilot AI review requested due to automatic review settings March 1, 2026 01:29
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the CoreFtp library by integrating full support for Active FTP mode. This addresses a critical limitation, allowing the client to connect to FTP servers that exclusively operate in active mode, thereby broadening the library's compatibility. The implementation includes new configuration options and a clever lazy-accept mechanism to manage the asynchronous nature of active data connections, all while maintaining existing passive mode functionality and ensuring backward compatibility.

Highlights

  • Active FTP Mode Support: Added comprehensive support for Active FTP mode (PORT/EPRT), enabling the library to connect to servers that do not support passive mode.
  • New Configuration Options: Introduced a new FtpDataConnectionType enum and ActiveExternalIp property in FtpClientConfiguration to allow explicit selection and configuration of active mode.
  • Lazy Data Stream Acceptance: Implemented ActiveDataStream, a wrapper stream that defers the acceptance of the server's inbound data connection until the first read operation, effectively solving the PORT/LIST sequencing deadlock.
  • Refactored Data Connection Logic: Refactored FtpClient to separate and manage passive and active data stream connection logic, including a new helper for formatting PORT commands.
  • Control Stream Enhancements: Enhanced FtpControlStream with a LocalIpAddress property and a WrapDataStreamAsync method to facilitate the wrapping of accepted data streams, including TLS support.
  • Integration Tests: Included new integration tests (ActiveModeTests.cs) to thoroughly verify the functionality and correctness of the Active FTP mode implementation.
  • Backward Compatibility: Ensured full backward compatibility, with AutoPassive remaining the default data connection type and no breaking changes introduced to existing functionality.
Changelog
  • src/CoreFtp/Enum/FtpCommand.cs
    • Added PORT and EPRT commands to the FtpCommand enumeration.
  • src/CoreFtp/Enum/FtpDataConnectionType.cs
    • Added a new enumeration FtpDataConnectionType with AutoPassive and Active options.
  • src/CoreFtp/FtpClient.cs
    • Refactored ConnectDataStreamAsync to delegate to ConnectActiveDataStreamAsync or ConnectPassiveDataStreamAsync based on configuration.
    • Implemented ConnectActiveDataStreamAsync to handle the setup for active mode data connections, including TcpListener and PORT command sending.
    • Added a private static helper method FormatPortCommand to construct the PORT command argument string.
  • src/CoreFtp/FtpClientConfiguration.cs
    • Added DataConnectionType property to specify the desired FTP data connection mode.
    • Added ActiveExternalIp property to allow explicit setting of the external IP for active mode, crucial for NAT scenarios.
  • src/CoreFtp/Infrastructure/Stream/ActiveDataStream.cs
    • Added a new ActiveDataStream class, which is a Stream wrapper that lazily accepts an inbound TcpClient connection on the first read/write operation.
  • src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs
    • Added a LocalIpAddress property to retrieve the local IP of the control connection.
    • Added a WrapDataStreamAsync method to encapsulate an accepted TcpClient into a data stream, applying TLS if the control connection is encrypted.
  • tests/CoreFtp.Tests.Integration/FtpClientTests/ActiveModeTests.cs
    • Added a new test class ActiveModeTests containing integration tests for ListAllAsync and ListFilesAsync in active mode, using a fake FTP server.
Activity
  • No human activity (comments, reviews, etc.) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Active FTP mode, enhancing server compatibility through a well-designed lazy-accept stream. However, it introduces security risks by accepting incoming data connections without source IP validation and binding the listener to all network interfaces, which could lead to data interception or manipulation. Furthermore, the current active mode is limited to IPv4 via the PORT command; support for EPRT is recommended for IPv6 compatibility. A potential deadlock risk in ActiveDataStream due to sync-over-async calls also needs addressing to ensure robustness.

return;

logger?.LogDebug( "[ActiveDataStream] Accepting inbound data connection from server" );
acceptedClient = await listener.AcceptTcpClientAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

In Active FTP mode, the client opens a local port and waits for the server to connect. The current implementation of ActiveDataStream.EnsureAcceptedAsync accepts the first TCP connection that arrives on the listening port without verifying the source IP address. An attacker who can connect to the client's listening port (e.g., by scanning or if the client is on a shared network) can intercept the data stream before the legitimate FTP server connects. This allows the attacker to either steal sensitive data being uploaded or inject malicious data into a download (e.g., a fake directory listing or a malicious file).

Comment on lines +64 to +68
public override int Read( byte[] buffer, int offset, int count )
{
EnsureAccepted();
return innerStream.Read( buffer, offset, count );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This method uses EnsureAccepted(), which in turn calls EnsureAcceptedAsync().GetAwaiter().GetResult(). This sync-over-async pattern is dangerous and can lead to deadlocks in certain environments (like UI or ASP.NET applications). To make the stream safer to use, it's better to prevent synchronous operations if a fully synchronous implementation isn't feasible. I recommend throwing NotSupportedException to enforce async-only usage.

        public override int Read( byte[] buffer, int offset, int count )
        {
            throw new NotSupportedException("Synchronous operations are not supported on this stream. Use ReadAsync instead.");
        }

Comment on lines +76 to +80
public override void Write( byte[] buffer, int offset, int count )
{
EnsureAccepted();
innerStream.Write( buffer, offset, count );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the Read method, this synchronous Write method relies on a sync-over-async call via EnsureAccepted(), which can cause deadlocks. To ensure the safety and reliability of the library, this synchronous method should be disallowed. Throwing NotSupportedException will guide users to the correct asynchronous API.

        public override void Write( byte[] buffer, int offset, int count )
        {
            throw new NotSupportedException("Synchronous operations are not supported on this stream. Use WriteAsync instead.");
        }

if ( string.IsNullOrEmpty( localIp ) )
throw new FtpException( "Could not determine local IP address for Active mode. Set ActiveExternalIp in configuration." );

var listener = new System.Net.Sockets.TcpListener( System.Net.IPAddress.Any, 0 );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The TcpListener is currently bound to IPAddress.Any, exposing the listening port on all network interfaces and increasing the attack surface. It is recommended to bind it specifically to the local IP address used for the control connection to mitigate this security risk. Furthermore, while addressing this, consider enhancing the active mode implementation within this method to support EPRT for IPv6 compatibility, as the current PORT command is limited to IPv4.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cc0c6376d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return;

logger?.LogDebug( "[ActiveDataStream] Accepting inbound data connection from server" );
acceptedClient = await listener.AcceptTcpClientAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound active accept wait to transfer timeout

EnsureAcceptedAsync waits on AcceptTcpClientAsync() with no cancellation or timeout, so when Active mode is misconfigured (e.g., wrong ActiveExternalIp, NAT/firewall blocks callback, or server never opens data connection) any LIST/RETR/STOR read/write can hang indefinitely and never surface the FTP error response from the control channel. This effectively ignores TimeoutSeconds for the most failure-prone part of Active mode and can stall callers permanently.

Useful? React with 👍 / 👎.

Comment on lines +39 to +40
acceptedClient = await listener.AcceptTcpClientAsync();
innerStream = await controlStream.WrapDataStreamAsync( acceptedClient );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate active data peer before wrapping socket

The first inbound connection is accepted and used as the transfer stream without checking that it comes from the same server as the control connection. In environments where another host can reach the client listener (especially plain FTP where PORT is visible on-path), a race connection can be accepted and treated as trusted data, allowing transfer corruption or data exfiltration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Active FTP (PORT) support to CoreFtp so data connections can be established when passive mode (EPSV/PASV) is unavailable, addressing Issue #43.

Changes:

  • Introduces FtpDataConnectionType and configuration options to select Active vs. existing passive behavior.
  • Implements Active-mode data connections via a listener + lazy accept stream, plus control-stream wrapping for accepted sockets (including TLS).
  • Adds integration tests using an in-process fake FTP server to validate Active-mode directory listing behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/CoreFtp.Tests.Integration/FtpClientTests/ActiveModeTests.cs Adds integration coverage for Active mode LIST-based operations.
src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs Adds local IP discovery and a helper to wrap accepted Active-mode data sockets (incl. TLS).
src/CoreFtp/Infrastructure/Stream/ActiveDataStream.cs Adds lazy-accept wrapper stream to avoid PORT/LIST sequencing deadlocks.
src/CoreFtp/FtpClientConfiguration.cs Adds configuration switches for data connection type and external IP advertisement.
src/CoreFtp/FtpClient.cs Adds Active-mode connection path and PORT command formatting helper.
src/CoreFtp/Enum/FtpDataConnectionType.cs Adds enum for selecting passive vs active data connections.
src/CoreFtp/Enum/FtpCommand.cs Adds PORT/EPRT commands to command enum.
Comments suppressed due to low confidence (2)

src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs:371

  • WrapDataStreamAsync() is declared public on a public type but appears to be an internal implementation detail for Active mode. Making it internal would avoid expanding the public API surface with a method that likely isn’t intended for external callers.
        public async Task<Stream> WrapDataStreamAsync( System.Net.Sockets.TcpClient acceptedClient )
        {
            Logger?.LogDebug( "[FtpSocketStream] Wrapping accepted data connection" );
            var socketStream = new FtpControlStream( Configuration, dnsResolver )
                { Logger = Logger, IsDataConnection = true };

tests/CoreFtp.Tests.Integration/FtpClientTests/ActiveModeTests.cs:22

  • Private readonly fields in the other FtpClient integration tests use an underscore prefix (_logger/_listener/_port). For consistency within this folder, consider renaming these fields to match the established convention.
        private readonly ILogger logger;
        private readonly TcpListener controlListener;
        private readonly int controlPort;


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +366 to +380
public async Task<Stream> WrapDataStreamAsync( System.Net.Sockets.TcpClient acceptedClient )
{
Logger?.LogDebug( "[FtpSocketStream] Wrapping accepted data connection" );
var socketStream = new FtpControlStream( Configuration, dnsResolver )
{ Logger = Logger, IsDataConnection = true };

socketStream.Socket = acceptedClient.Client;
socketStream.BaseStream = new NetworkStream( acceptedClient.Client );

if ( IsEncrypted )
{
await socketStream.ActivateEncryptionAsync();
}

return socketStream;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WrapDataStreamAsync() builds a data-connection stream from an accepted socket but doesn’t apply the same socket configuration as OpenDataStreamAsync()/ConnectSocketAsync (e.g., ReceiveTimeout / linger / any other options), which can change timeout behavior for Active mode transfers and potentially hang indefinitely. Consider mirroring the data-socket setup performed for passive connections before returning the wrapped stream.

Copilot uses AI. Check for mistakes.
Comment on lines +683 to +694
var localIp = Configuration.ActiveExternalIp ?? ControlStream.LocalIpAddress;
if ( string.IsNullOrEmpty( localIp ) )
throw new FtpException( "Could not determine local IP address for Active mode. Set ActiveExternalIp in configuration." );

var listener = new System.Net.Sockets.TcpListener( System.Net.IPAddress.Any, 0 );
listener.Start();

int localPort = ( (System.Net.IPEndPoint) listener.LocalEndpoint ).Port;

// Send PORT command: PORT h1,h2,h3,h4,p1,p2
string portCommand = FormatPortCommand( localIp, localPort );
var portResult = await ControlStream.SendCommandAsync( new FtpCommandEnvelope
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConnectActiveDataStreamAsync always uses PORT and FormatPortCommand assumes an IPv4 dotted-quad address. If ActiveExternalIp/LocalIpAddress is IPv6 (contains ':'), the generated command will be invalid and the listener is also IPv4-only (IPAddress.Any). Consider either implementing EPRT for IPv6 (and/or preferring EPRT with fallback), or explicitly validating and throwing a clear exception when a non-IPv4 address is supplied.

Copilot uses AI. Check for mistakes.
Comment on lines +714 to +720
private static string FormatPortCommand( string ip, int port )
{
string ipPart = ip.Replace( '.', ',' );
int highByte = port / 256;
int lowByte = port % 256;
return $"{ipPart},{highByte},{lowByte}";
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FormatPortCommand accepts a free-form string and does a simple '.'→',' replace; this will silently produce malformed PORT arguments if ActiveExternalIp is a hostname, contains whitespace, or includes an IPv6 literal. Consider parsing/validating the input with IPAddress.TryParse (and rejecting non-IPv4 for PORT), so configuration mistakes fail fast with a clear message.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +51
private void EnsureAccepted()
{
if ( accepted )
return;

EnsureAcceptedAsync().GetAwaiter().GetResult();
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnsureAccepted() blocks on EnsureAcceptedAsync().GetAwaiter().GetResult(), which is sync-over-async and can deadlock under a SynchronizationContext. For the synchronous Read/Write paths, prefer TcpListener.AcceptTcpClient() (fully sync) or start the accept in advance and wait on it, rather than blocking on an async method.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +43
private async Task EnsureAcceptedAsync()
{
if ( accepted )
return;

logger?.LogDebug( "[ActiveDataStream] Accepting inbound data connection from server" );
acceptedClient = await listener.AcceptTcpClientAsync();
innerStream = await controlStream.WrapDataStreamAsync( acceptedClient );
accepted = true;
logger?.LogDebug( "[ActiveDataStream] Data connection accepted" );
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnsureAcceptedAsync() uses AcceptTcpClientAsync() without any cancellation support, so ReadAsync/WriteAsync cancellation tokens won’t be able to abort a hung accept if the server never connects. Consider wiring cancellation through (e.g., AcceptTcpClientAsync(token) on newer TFMs, or Task.WhenAny with token + listener.Stop()), and ensure listener shutdown correctly unblocks the accept.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +41
logger?.LogDebug( "[ActiveDataStream] Accepting inbound data connection from server" );
acceptedClient = await listener.AcceptTcpClientAsync();
innerStream = await controlStream.WrapDataStreamAsync( acceptedClient );
accepted = true;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active mode currently accepts the first inbound connection to the listener without verifying it’s from the expected FTP server. This allows a different host to connect first and inject/consume data. Consider validating acceptedClient.RemoteEndPoint against the control connection’s remote address (or resolved server address) and rejecting unexpected peers before wrapping the stream.

Copilot uses AI. Check for mistakes.
Nick Briscoe added 3 commits March 1, 2026 14:23
- Validate active data peer IP matches control connection IP
- Fix sync-over-async in ActiveDataStream by properly overriding Memory/Span APIs and disallowing sync I/O
- Add cancellation/timeout support to ActiveDataStream accept logic
- Bind active mode listener explicitly to resolved IPv4 address instead of IPAddress.Any
- WrapDataStreamAsync applies LingerState and ReceiveTimeout correctly
- Refactored RetrieveDirectoryListing to be async instead of blocking byte-by-byte reads
- Ensure WrapDataStreamAsync API is internal
- Update test field naming conventions
@sparkeh9 sparkeh9 closed this Mar 1, 2026
@sparkeh9 sparkeh9 reopened this Mar 1, 2026
@sparkeh9 sparkeh9 merged commit e286ef5 into master Mar 1, 2026
1 check passed
@sparkeh9 sparkeh9 deleted the feature/active-ftp-mode branch March 1, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot OpenFileReadStreamAsync

2 participants