Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guest271314/ac9e26f71f5b299baa1b4663002ba9e6 to your computer and use it in GitHub Desktop.
Save guest271314/ac9e26f71f5b299baa1b4663002ba9e6 to your computer and use it in GitHub Desktop.
How to connect to a TCP server from an arbitrary Web page in the browser

Today we are going to connect to a TCP server from the browser (an arbitrary Web page).

We will be full-duplex streaming messages sent to the server and from the server to the browser.

Chrome has implemented Direct Sockets

The initial motivating use case is to support creating a web app that talks to servers and devices that have their own protocols incompatible with what’s available on the web. The web app should be able to talk to a legacy system, without requiring users to change or replace that system.

gated behind Isolated Web Apps (IWA)

This document proposes a way of building applications using web standard technologies that will have useful security properties unavailable to normal web pages. They are tentatively called Isolated Web Apps (IWAs). Rather than being hosted on live web servers and fetched over HTTPS, these applications are packaged into Web Bundles, signed by their developer, and distributed to end-users through one or more of the potential methods described below.

by modifying the Telnet Client Demo published by GoogleChromeLabs.

Let's start with out modification of the Telnet Client Demo.

We modify webpack.common.js to set the entry to index.js

module.exports = {
  entry: './src/index.js',
  // ...
}

Our index.html looks like this

<html>
<head>
  <meta charset="utf-8">
  <!-- webpackIgnore: true -->
  <link rel="manifest" href="manifest.webmanifest">
</head>
<body>
</body>
</html>

and our index.js looks like

(async () => {
  resizeTo(100, 100);
  globalThis.encoder = new TextEncoder();
  globalThis.decoder = new TextDecoder();

  globalThis.abortable = new AbortController();
  const {
    signal
  } = abortable;
  globalThis.signal = signal;
  globalThis.handle = null;
  globalThis.socket = null;
  globalThis.readable = null;
  globalThis.writable = null;
  globalThis.writer = null;
  globalThis.stream = null;
  globalThis.local = null;
  globalThis.channel = null;
  globalThis.socket = new TCPSocket('0.0.0.0', '8000');
  globalThis.stream = await socket.opened.catch(console.log);
  globalThis.readable = stream.readable;
  console.log(socket);
  globalThis.writable = stream.writable;
  globalThis.writer = writable.getWriter();
  globalThis.socket.closed.then(() => console.log('Socket closed')).catch(() => console.warn('Socket error'));
  globalThis.readable.pipeThrough(new TextDecoderStream()).pipeTo(
    new WritableStream({
      start(controller) {
        console.log('Starting TCP stream.');
      },
      write(value) {
        globalThis.channel.send(value);
      },
      close() {
        console.log('Socket closed');
      },
      abort(reason) {
        console.log({
          reason
        });
      }
    }), {
      signal
    }).then(() => console.log('pipeThrough, pipeTo Promise')).catch(() => console.log('caught'));

  const sdp = atob(new URL(location.href).searchParams.get('sdp'));
  local = new RTCPeerConnection({
    sdpSemantics: 'unified-plan',
  });
  [
    'onsignalingstatechange',
    'oniceconnectionstatechange',
    'onicegatheringstatechange',
  ].forEach((e) => local.addEventListener(e, console.log));

  local.onicecandidate = async ({
    candidate
  }) => {
    if (!candidate) {
      try {
        new Notification('IWA: Save SDP for WebRTC Data Channel')
          .onclick = async () => {
            globalThis.handle = await showSaveFilePicker({
              suggestedName: 'sdp.txt',
              startIn: 'downloads'
            });
            await new Blob([btoa(local.localDescription.sdp)], {
              type: 'text/plain'
            }).stream().pipeTo(await globalThis.handle.createWritable({
              keepExistingData: false
            }));
            blur();
          }
      } catch (e) {
        console.error(e);
      }
    }
  };
  channel = local.createDataChannel('transfer', {
    negotiated: true,
    ordered: true,
    id: 0,
    binaryType: 'arraybuffer',
    protocol: 'tcp',
  });

  channel.onopen = async (e) => {
    console.log(e.type);
  };
  channel.onclose = async (e) => {
    console.log(e.type);
    local.close();
    await writer.close().catch(console.log);
    abortable.abort('reason');
    close();
  };
  channel.onclosing = async (e) => {
    console.log(e.type);
  };
  channel.onmessage = async (e) => {
    console.log(e.data);
    await writer.write(e.data).catch(console.log);
  };
  await local.setRemoteDescription({
    type: 'offer',
    sdp
  });
  await local.setLocalDescription(await local.createAnswer());
})();

We generate our private.pem in the root of our fork of telnet-client with

openssl genpkey -algorithm ed25519 -out private.pem

Build our Signed Web Bundle with

npm run build

The IWA is served with the following headers

Content-Security-Policy:

base-uri 'none'; default-src 'self'; object-src 'none'; frame-src 'self' https: blob: data:; connect-src 'self' https:; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' https: blob: data:; media-src 'self' https: blob: data:; font-src 'self' blob: data:; require-trusted-types-for 'script'; frame-ancestors 'self';

Cross-Origin-Embedder-Policy:

require-corp

Cross-Origin-Opener-Policy:

same-origin

Cross-Origin-Resource-Policy:

same-origin

so we can't use opener and postMessage() to communicate with the IWA when we use open() to create a window point to the isolated-app: URL.

What we can do is make use of Web API's to exchange SDP: Session Description Protocol to establish WebRTC Data Channels connection between the arbitrary Web page. We'll use WICG File System Access API to write and read a local file containing the offer and and answer. We could alternatively use HTML <a> element with download attribute set and File API <input type="file">.

Our Node.js server is simple. We just decode the ArrayBuffer to text using Encoding API, convert to upper case, then encode to a Uint8Array and send back to the browser.

#!/usr/bin/env -S node

import {
  createServer
} from 'node:net';

const abortable = new AbortController();
const encoder = new TextEncoder();
const decoder = new TextDecoder();

const {
  signal
} = abortable;

const server = createServer({
  highWaterMark: 0,
  allowHalfOpen: true,
  noDelay: true
}, (socket) => {

  socket.on('data', (data) => {
    const response = decoder.decode(data).toUpperCase();
    console.log(data, response);
    socket.write(encoder.encode(response), null, () => {
      console.log('data written');
    });
  });

  socket.on('end', () => {
    console.log('Socket closed.');
    server.close();
  });

  socket.on('drain', (data) => {
    console.log('drain');
  });

  socket.on('error', (err) => {
    console.log({
      err
    });
  });

});

server.on('error', (e) => {
  console.log({
    e
  });
});

server.listen({
  port: 8000,
  host: '0.0.0.0',
  signal
}, () => {
  const {
    address,
    family,
    port
  } = server.address();
  console.log(`Listening on family: ${family}, address: ${address}, port: ${port}`);
});

We launch Chrome with the appropriate flags

chrome --install-isolated-web-app-from-file="/home/user/telnet-client/dist/telnet.swbn" \
--enable-features=IsolatedWebApps,IsolatedWebAppDevMode

On an arbitrary Web page we use open() to launch the IWA window, passing the offer from out WebRTC Data Channel as a query string parameter

var encoder = new TextEncoder();
var decoder = new TextDecoder();
var {
  resolve,
  reject,
  promise
} = Promise.withResolvers();
await navigator.permissions.request({
  name: 'notifications'
});
new Notification('Open IWA, connect to TCP server?').onclick = async () => {
  resolve(showOpenFilePicker({
    startIn: 'downloads',
    suggestedName: 'spd.txt'
  }));
}
var [handle] = await promise;
var {
  lastModified
} = await handle.getFile();
console.log(lastModified);
// FileSystemObserver() crashes tab on Linux, Chromium 118
(async () => {
  while (true) {
    const file = await handle.getFile();
    const {
      lastModified: modified
    } = file;
    if (modified > lastModified) {
      console.log(file.name, {
        modified,
        lastModified
      });
      break;
    }; 
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  var text = atob(await (await handle.getFile()).text());
  local.setRemoteDescription({
    type: 'answer',
    sdp: text
  });
})().catch(console.log);

var local = new RTCPeerConnection({
  sdpSemantics: 'unified-plan',
});
['onsignalingstatechange', 'oniceconnectionstatechange', 'onicegatheringstatechange', ].forEach((e) => local.addEventListener(e, console.log));

local.onicecandidate = async (e) => {
  if (!e.candidate) {
    local.localDescription.sdp = local.localDescription.sdp.replace(/actpass/, 'active');
    if (local.localDescription.sdp.indexOf('a=end-of-candidates') === -1) {
      local.localDescription.sdp += 'a=end-of-candidates\r\n';
    }
    try {
      console.log('sdp:', local.localDescription);
      var w = open(`isolated-app://<ID>?sdp=${btoa(local.localDescription.sdp)}`, 'iwa');
    } catch (e) {
      console.error(e);
    }
  }
};
var channel = local.createDataChannel('transfer', {
  negotiated: true,
  ordered: true,
  id: 0,
  binaryType: 'arraybuffer',
  protocol: 'tcp',
});

channel.onopen = async (e) => {
  console.log(e.type);
};
channel.onclose = async (e) => {
  console.log(e.type);
};
channel.onclosing = async (e) => {
  console.log(e.type);
};
channel.onmessage = async (e) => {
  // Do stuff with data
  console.log(e.data);
};

var offer = await local.createOffer({
  voiceActivityDetection: false
});
local.setLocalDescription(offer);

After the open event is dispatched we write to the TCP socket

channel.send(encoder.encode('test'))

To close the socket and the IWA we call channel.close().

Source code: https://github.com/guest271314/telnet-client/tree/user-defined-tcpsocket-controller-web-api

Conclusion:

  • I don't think it is possible to completely isolate a window on the Web, using any combination of headers. Why: The example above uses only Web API's without browser extension code to establish communication between the IWA and the arbitrary Web page. This is also possible using browser extension code https://github.com/guest271314/telnet-client/tree/user-defined-tcpsocket-controller.

  • When one API is married to another API (Direct Sockets gated behind Isolated Web Apps) it is possible that users will sacrifice the API serving as the gate to get to the API behind the gate, nullifying the gate by any means necessary.

  • API proposal authors, in general, don't trust users to define their own permissions policies. The Isolated Web Apps and Direct Sockets API's, respectively, can provide a means for users to define which Web sites and under what cases TCPSocket() and UDPSocket()` are exposed to the Web site; which in this case would preserve IWA isolation instead of exposing the case where IWA can be worked around so the app is not isolated, ostensibly the selling point of Signed Web Bundles and Isolated Web Apps.

Discussion:

Questions, comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment