Screen capture on a LAN web app needs https, and macOS refuses a lazy self-signed certificate

My team’s internal portal runs on plain http on the LAN, at http://192.168.1.82:8888. I wanted its note editor to grab a screenshot of another window straight from the browser, Flameshot-style, with getDisplayMedia(). On that address the API isn’t there at all: browsers only offer screen capture in a secure context, and an http page on a LAN IP is not one.

The fix is https next to http, with a certificate signed by a small local CA that each computer trusts once. My first attempt, the usual one-line self-signed openssl req -x509, made the wrong kind of certificate: a CA certificate with no serverAuth usage, which Apple’s rules reject.

The wall: no secure context, no getDisplayMedia

MDN is blunt about both conditions on getDisplayMedia():

1
2
Secure context: This feature is available only in secure contexts (HTTPS) ...
Transient user activation is required.

And secure contexts are https, 127.0.0.0/8, ::1 and localhost. A LAN IP over http matches none of them. That is also why the feature worked when I tested on 127.0.0.1 and did nothing on the real address: loopback counts as secure, the LAN IP doesn’t.

I checked the http case from a headless Chrome (more on how below). isSecureContext was false and navigator.mediaDevices?.getDisplayMedia was missing:

1
PASS http page by name is not a secure context, no getDisplayMedia

Other ways around it, and why I didn’t take them:

  • Chrome’s --unsafely-treat-insecure-origin-as-secure=http://192.168.1.82:8888 flag works, but every colleague would have to set it in every browser.
  • An SSH tunnel to localhost works for me and nobody else.

So: serve https.

https beside http in Tornado

The portal is one Tornado process. I kept http on its port, untouched. Scripts and bookmarks use it, so there’s no redirect and no HSTS. Three new options, off by default:

local.conf (this machine):

1
2
3
ssl_port = 8889
certfile = "/home/william/git/SnailPortal2/run/tls.crt"
keyfile = "/home/william/git/SnailPortal2/run/tls.key"

The usual 8443 was already taken here by something else:

1
ss -ltn | grep ":8443 "
1
2
LISTEN  0        4096             0.0.0.0:8443           0.0.0.0:*
LISTEN  0        4096                [::]:8443              [::]:*

snailportal/__main__.py builds the TLS context once, before anything else starts, so a bad file stops startup with the reason instead of failing on the first request:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def tls_context(certfile: str, keyfile: str) -> ssl.SSLContext:
    """The https listener's TLS context; exits with the reason when a file is missing or bad."""
    if not (certfile and keyfile):
        raise SystemExit("--ssl_port needs --certfile and --keyfile")
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    try:
        ctx.load_cert_chain(certfile, keyfile)
    except OSError as e:  # ssl.SSLError too
        raise SystemExit(f"--ssl_port: cannot load {certfile} / {keyfile}: {e}") from None
    return ctx

and listens twice with the same app:

1
2
3
app.listen(options.port, address=options.address)
if tls:
    app.listen(options.ssl_port, address=options.address, ssl_options=tls)
1
2
SnailPortal listening on http://192.168.1.82:8888
SnailPortal listening on https://192.168.1.82:8889

Nothing else on the server changed. The cookies have no secure flag, so they work on both. The page’s WebSocket already picked its scheme from the page:

1
const proto = location.protocol === "https:" ? "wss" : "ws";

First try: the one-line self-signed certificate

This is the line everyone copies, with the IP in subjectAltName because Chrome ignores the common name:

1
2
openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout run/tls.key -out run/tls.crt \
    -subj "/CN=snailportal" -addext "subjectAltName=IP:192.168.1.82,DNS:localhost,IP:127.0.0.1"

It serves, and any browser shows the “not private” warning as expected:

1
2
subject=CN = snailportal
Verify return code: 18 (self-signed certificate)

Then I looked at what it actually is:

1
openssl x509 -in run/tls.crt -noout -text | grep -A1 -E "Basic Constraints|Key Usage|Extended Key|Subject Alternative"
1
2
3
4
            X509v3 Basic Constraints: critical
                CA:TRUE
            X509v3 Subject Alternative Name:
                IP Address:192.168.1.82, DNS:localhost, IP Address:127.0.0.1

req -x509 with the default config makes a CA certificate, and there is no key usage or extended key usage at all. Apple’s Requirements for trusted certificates in iOS 13 and macOS 10.15 say a TLS server certificate must have the id-kp-serverAuth extended key usage, must be valid for 825 days or fewer, and must carry the name in subjectAltName, and that “connections to TLS servers violating these new requirements will fail”. I didn’t get to watch it fail on a Mac, but a certificate that breaks a documented rule isn’t worth shipping to find out.

A local CA that can only vouch for this address

What mkcert does, in a dozen lines of openssl. A CA made once, and a server certificate it signs, the right shape for a server. Computers trust the CA once. The server certificate can be renewed as often as needed without anyone importing anything again.

scripts/tls-cert.sh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env bash
# The https listener's certificate (ssl_port, certfile, keyfile in local.conf): a small local
# certificate authority, made once, signs a certificate for the portal's LAN address. Trust the CA
# once per computer and the https address opens without a warning; it can vouch only for these
# addresses (nameConstraints), so trusting it can't be abused for other sites.
#
#   scripts/tls-cert.sh 192.168.1.82   # run/tls-ca.crt (+ .key, kept when present), run/tls.crt + run/tls.key
set -euo pipefail

IP="${1:?usage: scripts/tls-cert.sh <LAN IP of the portal>}"
RUN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/run"
mkdir -p "$RUN"
umask 077 # the keys readable by this user only

if [[ ! -f "$RUN/tls-ca.key" ]]; then
    openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=SnailPortal local CA" \
        -keyout "$RUN/tls-ca.key" -out "$RUN/tls-ca.crt" \
        -addext "keyUsage=critical,keyCertSign,cRLSign" \
        -addext "nameConstraints=permitted;IP:$IP/255.255.255.255,permitted;IP:127.0.0.1/255.255.255.255,permitted;DNS:localhost"
fi
# macOS refuses a server certificate without serverAuth, or valid for over 825 days.
openssl req -newkey rsa:2048 -nodes -subj "/CN=$IP" -keyout "$RUN/tls.key" -out "$RUN/tls.csr"
openssl x509 -req -in "$RUN/tls.csr" -CA "$RUN/tls-ca.crt" -CAkey "$RUN/tls-ca.key" -CAcreateserial -days 825 \
    -out "$RUN/tls.crt" -extfile <(printf '%s\n' "basicConstraints=critical,CA:FALSE" \
        "keyUsage=critical,digitalSignature,keyEncipherment" "extendedKeyUsage=serverAuth" \
        "subjectAltName=IP:$IP,DNS:localhost,IP:127.0.0.1")
rm -f "$RUN/tls.csr"
chmod 644 "$RUN/tls-ca.crt" "$RUN/tls.crt"
openssl verify -CAfile "$RUN/tls-ca.crt" "$RUN/tls.crt"
echo "Trust $RUN/tls-ca.crt on each computer, then restart the portal."
1
scripts/tls-cert.sh 192.168.1.82
1
2
3
4
Certificate request self-signature ok
subject=CN = 192.168.1.82
/home/william/git/SnailPortal2/run/tls.crt: OK
Trust /home/william/git/SnailPortal2/run/tls-ca.crt on each computer, then restart the portal.

Same grep on the new server certificate: it now has what Apple asks for.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
            Not After : Jan 31 12:46:43 2029 GMT
        Subject: CN = 192.168.1.82
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Subject Alternative Name:
                IP Address:192.168.1.82, DNS:localhost, IP Address:127.0.0.1

And the CA is fenced in:

1
2
3
4
5
6
7
8
9
            X509v3 Basic Constraints: critical
                CA:TRUE
            X509v3 Key Usage: critical
                Certificate Sign, CRL Sign
            X509v3 Name Constraints:
                Permitted:
                  IP:192.168.1.82/255.255.255.255
                  IP:127.0.0.1/255.255.255.255
                  ...

A few details that matter:

  • nameConstraints is the reason this is safe to hand out. Asking colleagues to trust a root CA normally means trusting its key for every site on the internet. This one can only sign for 192.168.1.82, 127.0.0.1 and localhost, so even a stolen tls-ca.key can’t forge a bank’s certificate on a client that enforces the constraint.
  • I left nameConstraints non-critical. A client that understands it enforces it either way; marking it critical only makes a client that doesn’t understand it reject the whole CA.
  • The CA lives ten years. The server certificate gets 825 days, the macOS limit. Rerunning the script keeps the CA and replaces only the server certificate.
  • umask 077 makes both keys -rw-------. Only the two .crt files are opened up to 644. run/ is git-ignored.

Check it the way a browser will

curl with the CA as its only trust root verifies the chain, the IP against subjectAltName and the CA’s name constraints. That is what a browser does once the CA is trusted:

1
curl -s --noproxy '*' --cacert run/tls-ca.crt https://192.168.1.82:8889/api/health
1
{"status": "ok", "version": "0.1.0"}

Without it, against the system store, it refuses (exit 60 is “peer certificate cannot be authenticated”):

1
curl -s --noproxy '*' -o /dev/null https://192.168.1.82:8889/api/health; echo $?
1
60

(--noproxy '*' because this machine has a proxy in its environment that answers LAN addresses with a 503.)

Trust the CA once per computer

Copy run/tls-ca.crt to the computer (it’s public; only the .key files are secret), then:

  • macOS (Chrome and Safari): double-click the file to add it to Keychain Access, open “SnailPortal local CA”, Trust, set “When using this certificate” to Always Trust, close and enter your password.
  • Windows (Chrome, Edge): double-click, Install Certificate, Current User, place it in Trusted Root Certification Authorities.
  • Firefox (its own store, on every OS): Settings, Privacy & Security, Certificates, View Certificates, Authorities, Import, tick “Trust this CA to identify websites”.
  • Chrome on Linux: Settings, Privacy and security, Security, Manage certificates, Authorities, Import, tick “Trust for identifying websites”.

Restart the browser. To make sure the file that arrived is the one you made, compare fingerprints:

1
openssl x509 -in run/tls-ca.crt -noout -fingerprint -sha256

The browser side

With a secure context, the capture itself is short. Two things are easy to get wrong.

Call it inside the click. getDisplayMedia() needs transient user activation, so the editor’s Screenshot button opens the dialog and calls capture in the same click handler, before any await that could outlive the gesture. The browser’s window/screen picker comes up straight away.

Wait for a real frame. A window capture’s first frame can still be blank when play() resolves:

1
2
3
4
5
6
7
8
9
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, selfBrowserSurface: "exclude", surfaceSwitching: "exclude" });
const video = document.createElement("video");
video.muted = true;
video.srcObject = stream;
await video.play();
// A window's first frame can be blank: wait for one to be presented (0.5 s at most).
await new Promise((ok) => { video.requestVideoFrameCallback?.(ok); setTimeout(ok, 500); });
if (opened) show(await createImageBitmap(video)); // dropped if the dialog closed meanwhile
// ...and in finally: stream.getTracks().forEach((t) => t.stop());

selfBrowserSurface: "exclude" keeps the portal’s own tab out of the picker. Stopping every track right after the grab ends the share, so the capture lasts only as long as one frame takes.

On the http address the dialog can’t capture, so it says why and links to the same page on https (https://<host>:<ssl_port><path>), opened in a new tab so an unsaved note stays where it is.

One cost of the https address: an http:// picture or embedded page inside a note is mixed content there and won’t load. The http address still shows it.

Testing it headless

Three Chrome tricks made this testable without a desktop:

1
2
3
4
5
google-chrome --headless=new --remote-debugging-port=<port> --user-data-dir=<scratch dir> \
    --no-first-run --no-proxy-server \
    --ignore-certificate-errors \
    --use-fake-ui-for-media-stream --use-fake-device-for-media-stream \
    --host-resolver-rules="MAP snailtest.internal 127.0.0.1"
  • --use-fake-ui-for-media-stream --use-fake-device-for-media-stream answer the picker and hand back Chrome’s fake 800×600 video. A real tab or screen source doesn’t start headless (NotReadableError: Could not start video source), so this tests the code path, not a real window.
  • 127.0.0.1 is a secure context, so a test portal on loopback can never show the http behaviour. --host-resolver-rules gives it a name that isn’t loopback: http://snailtest.internal:8898 is plain http and insecure, while still reaching the local test server.
  • My first run hung on that name: Chrome sent snailtest.internal to the proxy in the environment, which had never heard of it. --no-proxy-server fixed it.
1
Error: timed out waiting for document.readyState === "complete" && (!!document.querySelector("#username") || !!document.querySelector("#dn-add"))

With those, against a test instance on http 8898 and https 8453:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
PASS https page is a secure context with getDisplayMedia
PASS live WebSocket connects over wss
PASS one click on Screenshot opens the annotator with a captured frame: [800,600]
PASS header capture-again button shown
PASS capture again takes a new frame
PASS a box drawn on the capture
PASS Insert uploads and puts a figure in the details
PASS the picture is served as a PNG over https
PASS http page by name is not a secure context, no getDisplayMedia
PASS empty state links to the https address with this path: {"shown":true,"href":"https://snailtest.internal:8453/"}
PASS no capture buttons on http
PASS no page errors: []
12/12 passed

What this doesn’t cover: a capture of a real window, importing the CA on a real Mac, and whether port 8889 gets through to other machines on the LAN.

Takeaways

  1. getDisplayMedia() needs a secure context. https, or loopback. A LAN IP over http is neither, which is why “it works on localhost” means nothing here.
  2. Add https; keep http. A second listen() with an SSLContext, no redirect. WebSockets that follow location.protocol need no change.
  3. The one-line openssl req -x509 makes a CA certificate. A server certificate wants CA:FALSE, extendedKeyUsage=serverAuth, the name in subjectAltName, and 825 days or fewer for Apple.
  4. Use a local CA with nameConstraints. Trust it once per computer, renew the server certificate freely, and nobody is trusting a key that could sign for other sites.
  5. Test the insecure branch through a hostname. 127.0.0.1 is secure by definition; --host-resolver-rules gives you an insecure name for the same server.

References

相关内容

william 支付宝支付宝
william 微信微信
0%