Scaling Privileged Access for Modern Infrastructure: Real-World Insights
Apr 25
Virtual
Register Today
Teleport logo

Teleport Blog - Directory Sharing in a Web-Based RDP Client Using the File System Access API - Sep 19, 2022

Directory Sharing in a Web-Based RDP Client Using the File System Access API

by Isaiah Becker-Mayer

Directory sharing

Remote Desktop Protocol (RDP) is a protocol developed by Microsoft which at its core is designed to give users a graphical interface to a remote Windows computer over a network connection. The remote Windows machine runs an RDP server, while the local computer accessing it runs an RDP client. Windows comes bundled with Microsoft's Remote Desktop Connection to easily access Windows hosts over RDP.

Since its initial release with Windows NT 4.0 Server (1996), RDP has come to be treated as a fundamental module in the Windows operating system. Nowadays, the canonical RDP server implementation is built into each Windows release, and an officially supported client for most popular operating systems (except Linux) and the web is built and maintained by Microsoft. Despite technically being a proprietary protocol, Microsoft publishes the specification, which has allowed alternative implementations to gain a foothold over the years, the most notable being the open source project FreeRDP.

The core RDP protocol provides basic functionality of remote access such as sending mouse and keyboard input from the client, and receiving images of the screen from the Windows server. Alongside those basic features, the core RDP protocol introduces the concept of virtual channels, which can be used to build software extensions that add additional features such as support for special hardware, clipboard sharing and — the focus of this post — file sharing via a shared directory.

RDPDR: Designed for client developers

RDP virtual channels are identified by a channel name, which in the case of directory sharing is "RDPDR" (short for RDP Drive Redirection). This extension is officially supported by Microsoft and is published here.

RDPDR appears to have been designed with an eye on making the RDP client's implementation easy. This is evidenced by the fact that (after the initial announcement of a new directory for sharing) the protocol always uses a pattern of:

  1. The RDP server requests some information about the shared directory at the exact moment that it's needed.
  2. The RDP client responds with that information.

To clarify this point, let's take a look at the set of messages one would send to read the first 1024 bytes of data from a file in the shared directory:

  1. The RDP server sends a Device Create Request, which tells the client to create a new file handle for the file being read from, as identified by the Path parameter.
  2. The RDP client sends a Device Create Response containing a FileId parameter which the RDP server can use to identify this newly created file handle in subsequent requests.
  3. The RDP server sends a Device Read Request with Length set to 1024 and Offset set to 0, asking the client to read 1024 bytes from the beginning of the given file.
  4. The RDP client sends a Device Read Response which contains those 1024 bytes at the beginning of the file.
  5. The RDP server sends a Device Close Request telling the RDP client to delete the file handle it created between steps 1 and 2.
  6. The RDP client sends a Device Close Response acknowledging that the file handle has been closed.

Such a sequence of messages will typically be sent when the user does something like open a shared file in a text editor.

One can imagine a counterfactual world where the RDP designers weren't so merciful on us client developers, and opted for a pub-sub system instead: one where the RDP server subscribes to updates to every file and directory in the shared directory, and the RDP client is responsible for listening for updates on the local machine and publishing them to the server in real time. Such a design would force RDP client developers to dig deep into the operating system and intercept low level calls in order to translate and transmit them over to the RDP server whenever they occurred.

Instead we are just tasked with the relatively simple job of creating file handles, operating on them, and closing them according to the RDP server's demands. The design tradeoff here is that it is easier for shared files to get out of sync as compared to the imaginary pub-sub system described above, for example if a single file is being edited on both the client and server side of the equation. However this problem is no different than that of a file being edited in multiple separate editor windows at once in a non-RDP situation, something that users are already responsible for in general. Therefore the tradeoff in favor of client simplicity was a good one by Microsoft's RDP team, and gets us part of the way to our destination.

Directory sharing on the Web

It should be clear from the previous section that RDP takes the concept of a file handle (aka file descriptor) for granted. On any operating system that supports C (that is to say all popular operating systems), this assumption is valid. However when it comes to the web, not until recently was anything resembling a C-style file handle available to developers.

State of the art for directory sharing: native vs Web

Because of this file handle deficiency, Web RDP clients have always had a UX disadvantage as compared to native clients by no fault of their own. Under the hood, these first-generation web clients are wiring together traditional HTML <input> elements and the File API with RDPDR to give a rough approximation of a shared network drive. Due to this limitation, users are forced to go through the browser's invasive and clunky upload and download manager to transfer files to and from the shared directory. In contrast, native clients like FreeRDP's are able to offer a seamless shared directory experience where the target directory works precisely like an ordinary network drive, with users simply moving a file into the shared directory on the client side to "upload" local resources, and on the remote computer to "download" it.

Download

Guacamole web clientGuacamole download
Guacamole download
FreeRDP native clientFreeRDP download
FreeRDP download

Upload

Guacamole web clientGuacamole upload
Guacamole upload
FreeRDP native clientFreeRDP upload
FreeRDP upload

The File System Access API: file handles for the Web

For most of these web clients' history, this was simply the best we could do. However with the advent of the File System Access API, it has just recently become possible to get something like a file handle in a browser, and therefore give web-based RDP clients a directory sharing experience on par with any native client. From a high level, File System Access gives developers the ability to perform basic CRUD operations within a specific directory on the browser-user's computer. (Of note is that the relevant features of this API are, at the time of this writing, only available in Chromium-based browsers, such as Chrome or Opera).

In order to explore this API in more detail, let's build out part of a client capable of handling the sequence of RDP messages for reading from a file given as an example in the section above. For the sake of brevity I'll be taking some shortcuts here, which I will do my best to point out along the way. In general we'll just be building out a FileSystemAccessManager, which we will assume is meant to be a member of an RDPClient class (not shown in the examples).

The RDP server sends a Device Create Request

This request tells us to create a new file handle for the file specified by the Path parameter.

Let's assume we've already selected a directory to share, and that its corresponding FileSystemDirectoryHandle is saved in our client as sharedDirHandle. We'll also add a Map of FileIds to FileSystemFileHandle or FileSystemDirectoryHandle, which is where we'll eventually store our new file handle.


class FileSystemAccessManager {
  private sharedDirHandle: FileSystemDirectoryHandle;
  private handleCache: Map<number, FileSystemDirectoryHandle | FileSystemFileHandle>;
  private nextFileId = 0; // We'll find use for this later

  constructor(sharedDirHandle: FileSystemDirectoryHandle) {
    this.sharedDirHandle = sharedDirHandle;
    this.handleCache = new Map();
  }
}

We'll assume the RDPClient is responsible for deserializing the Device Create Request and then calling the FileSystemAccessManager with the Path parameter. We can also assume that Paths are in the form of relative traditional DOS paths, which describe a path from our shared directory (sharedDirHandle) to some of its contents. In order to manage these paths in a general way, we'll create a function called walkPath to traverse the path and give us the FileSystemFileHandle or FileSystemDirectoryHandle at its end.


class FileSystemAccessManager {
  private sharedDirHandle: FileSystemDirectoryHandle;
  private handleCache: Map<number, FileSystemDirectoryHandle | FileSystemFileHandle>;
  private nextFileId = 0;

  constructor(sharedDirHandle: FileSystemDirectoryHandle) {
    // omitted
  }

  /**
   * walkPath walks a path, returning the
   * FileSystemDirectoryHandle | FileSystemFileHandle
   * it finds at its end.
   * @throws {Error} if the path isn't a valid path in the shared directory
   */
  private async walkPath(
    path: string
  ): Promise<FileSystemDirectoryHandle | FileSystemFileHandle> {
    // This is an idiosyncracy of RDPDR: an empty string as the Path
    // means the shared directory itself.
    if (path === '') {
      return this.sharedDirHandle;
    }

    let pathList = path.split('\\');

    let walkIt = async (
      dir: FileSystemDirectoryHandle,
      pathList: string[]
    ): Promise<FileSystemDirectoryHandle | FileSystemFileHandle> => {
      // Pop the next path element off the stack
      let nextPathElem = pathList.shift();

      // Iterate through the items in the directory
      for await (const entry of dir.values()) {
        // If we find the entry we're looking for
        if (entry.name === nextPathElem) {
          if (pathList.length === 0) {
            // We're at the end of the path, so this
            // is the end element we've been walking towards.
            return entry;
          } else if (entry.kind === 'directory') {
            // We're not at the end of the path and
            // have encountered a directory, recurse
            // further.
            return walkIt(entry, pathList);
          } else {
            break;
          }
        }
      }

      throw new Error('path does not exist');
    };

    return walkIt(this.sharedDirHandle, pathList);
  }
}

Now that we can handle paths in general, we can create a method to save our file handle in our handleCache. This method will also generate and return a FileId for this file.


class FileSystemAccessManager {
  private sharedDirHandle: FileSystemDirectoryHandle;
  private handleCache: Map<number, FileSystemDirectoryHandle | FileSystemFileHandle>;
  private nextFileId = 0;

  constructor(sharedDirHandle: FileSystemDirectoryHandle) {
    // omitted
  }

  private async walkPath(
    path: string
  ): Promise<FileSystemDirectoryHandle | FileSystemFileHandle> {
    // omitted
  }

  /**
   * Creates a new entry in the file cache for the file at path.
   * @throws {Error} if the path isn't a valid path in the shared directory
   */
  async handleCreate(path: string): number {
    const handle = await this.walkPath(path);
    const fileId = this.nextFileId++;
    this.handleCache.set(fileId, handle);

    return fileId;
  }
}

Now the RDPClient, upon receipt of a Device Create Request, can deserialize it and call FileSystemAccessManager.handleCreate(path), and use the returned FileId in the corresponding Device Create Response it's responsible for sending back to the server. Internally, our FileSystemAccessManager has a file handle in its handleCache, which will be useful for the next request.

The RDP server sends a Device Read Request

This RDP request will have a FileId in its Device I/O Request header, which specifies which file is meant to be read. In the case of our example, this will be the fileId we generated and returned in handleCreate. The request will also contain a Length field, denoting how many bytes are requested to be read, and an Offset field, which specifies how many bytes from the start of the file we should start the read from.


class FileSystemAccessManager {
  private sharedDirHandle: FileSystemDirectoryHandle;
  private handleCache: Map<number, FileSystemDirectoryHandle | FileSystemFileHandle>;
  private nextFileId = 0;

  // omitted

  /**
   * Reads length bytes starting at offset from a file specified by fileId
   * @throws {Error} if the fileId is not to a valid file handle
   */
  async handleRead(
    fileId: number,
    offset: number,
    length: number
  ): Promise<Uint8Array> {
    // Grab the file from our handleCache by fileId.
    const fileHandle = await this.handleCache.get(fileId);

    // If the file doesn't exist or is a directory,
    // throw an Error.
    if (!fileHandle || fileHandle.kind !== 'file') {
      throw new Error('invalid fileId');
    }

    // Get a File for reading.
    const file = await fileHandle.getFile();

    // Read length bytes starting at offset.
    const rawBytes = await file.slice(offset, offset + length).arrayBuffer();

    // Return a Uint8Array of the raw bytes.
    return new Uint8Array(rawBytes);
  }
}

The returned Uint8Array can be used by our imaginary RDPClient to fill out the Length and ReadData fields in a corresponding Device Read Response.

The RDP server sends a Device Close Request

To close out the operation, we just need to add some simple logic to our FileSystemAccessManager for closing the file handle given its FileId.


class FileSystemAccessManager {
  private sharedDirHandle: FileSystemDirectoryHandle;
  private handleCache: Map<number, FileSystemDirectoryHandle | FileSystemFileHandle>;
  private nextFileId = 0;

  // omitted

  /**
   * Removes a handle from the handleCache by fileId, returning
   * true if the handle was successfully removed and false otherwise.
   */
  handleClose(fileId: number): boolean {
    return this.handleCache.delete(fileId);
  }
}

Finally, the RDPClient can use the return value to determine whether to send a success or failure in the corresponding Device Close Response.

Limitations

While most RDPDR operations can be fielded via the File System Access API in a manner similar to the example above, it's important to note that there are some limitations of the API relative to RDP.

At the time of writing, FileSystemFileHandle.move() is behind a feature flag and FileSystemDirectoryHandle.move() remains unimplemented, meaning such operations will need to be carefully implemented by manually combining read(), write(), and delete().

Additionally, some time information that RDP sometimes wants is unavailable via the File System Access API. For example a Client Drive Query Directory Response might need to contain a FileBasicInformation object, which has fields for CreationTime, LastAccessTime, LastWriteTime, and ChangeTime, whereas the browser's FileSystemFileHandle can only give us the file's lastModified time, and a FileSystemDirectoryHandle doesn't have even that. In such cases, client developers can just fill all these fields out with lastModified, and/or choose a standardized time to give them such as the Unix epoch. This will have some ramifications for i.e. file sorting when these files are displayed on the Windows machine, but for most applications the effects will be minimal.

What if I don't want to build my own client?

First of all, let me commend you for your wise decision. Building an RDP client with RDPDR support is no walk in the park, and there are plenty of battle-tested options out there to choose from. For simple use cases where you need to access a few static machines, a Microsoft supported official client can often do the trick. Or, if you prefer an open source solution, FreeRDP and Guacamole are well regarded.

Teleport Desktop Access

Teleport's native-equivalent web clientFreeRDP upload
FreeRDP upload

On the other hand, if you need to manage access for larger groups of users to greater quantities of machines, you should take a look at the tool we're building called Teleport Desktop Access. On top of giving you the world's first web browser-based RDP client with native-equivalent directory sharing support for Windows, Linux and MacOS (hey, how do you think I know about all these arcane details in the first place?), we also provide integrated access management across a variety of other protocols including SSH, many databases and Kubernetes, as well as state-of-the-art security features like passwordless authentication and session recording. Best of all, even if you're in the first category and only need to manage access for a relatively modest number of people and machines, you can try out most Teleport features (including Desktop Access) as part of our free, open source offering.

Directory Sharing for Teleport Desktop Access can be configured on a per-role basis. Visit Teleport Documentation for more information.

Tags

Teleport Newsletter

Stay up-to-date with the newest Teleport releases by subscribing to our monthly updates.

background

Subscribe to our newsletter

PAM / Teleport