shelldeck 0.0.3__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {shelldeck-0.0.3 → shelldeck-0.0.4}/CHANGELOG.md +20 -0
  2. {shelldeck-0.0.3 → shelldeck-0.0.4}/PKG-INFO +11 -6
  3. {shelldeck-0.0.3 → shelldeck-0.0.4}/README.md +10 -5
  4. {shelldeck-0.0.3 → shelldeck-0.0.4}/pyproject.toml +1 -1
  5. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/auth.py +18 -1
  6. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/cli.py +54 -4
  7. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/server.py +160 -16
  8. {shelldeck-0.0.3 → shelldeck-0.0.4}/tests/test_cli.py +12 -3
  9. {shelldeck-0.0.3 → shelldeck-0.0.4}/tests/test_shelldeck.py +86 -8
  10. {shelldeck-0.0.3 → shelldeck-0.0.4}/.gitignore +0 -0
  11. {shelldeck-0.0.3 → shelldeck-0.0.4}/LICENSE +0 -0
  12. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/__init__.py +0 -0
  13. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/__main__.py +0 -0
  14. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/addons/__init__.py +0 -0
  15. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/addons/fast_context.py +0 -0
  16. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/db.py +0 -0
  17. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/gitgraph.py +0 -0
  18. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/bash.sh +0 -0
  19. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.fish +0 -0
  20. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.ps1 +0 -0
  21. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshenv +0 -0
  22. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshrc +0 -0
  23. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/pty.py +0 -0
  24. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/runner.py +0 -0
  25. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/scheduler.py +0 -0
  26. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/shells.py +0 -0
  27. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/app.css +0 -0
  28. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/app.js +0 -0
  29. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/devices.js +0 -0
  30. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/gitgraph.js +0 -0
  31. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/history.js +0 -0
  32. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon-192.png +0 -0
  33. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon-32.png +0 -0
  34. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon-512.png +0 -0
  35. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon.svg +0 -0
  36. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/index.html +0 -0
  37. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/manifest.webmanifest +0 -0
  38. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/monitor.js +0 -0
  39. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/ui.js +0 -0
  40. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
  41. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-fit.js +0 -0
  42. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-search.js +0 -0
  43. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-serialize.js +0 -0
  44. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-web-links.js +0 -0
  45. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-webgl.js +0 -0
  46. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.css +0 -0
  47. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.js +0 -0
  48. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/views.js +0 -0
  49. {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/stats.py +0 -0
  50. {shelldeck-0.0.3 → shelldeck-0.0.4}/tests/test_gitgraph.py +0 -0
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to shelldeck are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.0.4] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - Share approval: opening a share link puts the device on a waiting page until you allow it in the `sd share` window (`Allow it? [y/N]`); then it still needs the password.
10
+ - `sd share --new-link` prints another one-use link for the running share.
11
+ - Security headers on every response (no framing, no MIME sniffing, no referrer) and `Cache-Control: no-store` on the API.
12
+
13
+ ### Changed
14
+
15
+ - Share links work once and expire after 10 minutes unopened. A new link voids the unused old one.
16
+ - Sharing needs a password of 12+ characters (an existing one that long counts after the next login).
17
+ - Revoking a shared device in Devices also voids its approval, so it needs a new link.
18
+ - README: `sd share` is encrypted in transit but not end to end; Cloudflare can see the traffic.
19
+
20
+ ### Fixed
21
+
22
+ - A share left open when `sd share` was killed or its window closed: the server now closes a share within a minute of its last heartbeat and signs its browsers out. Devices no longer shows "sharing via" a dead tunnel.
23
+
5
24
  ## [0.0.3] - 2026-09-29
6
25
 
7
26
  ### Added
@@ -48,6 +67,7 @@ First public release.
48
67
  - Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
49
68
  - `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
50
69
 
70
+ [0.0.4]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.4
51
71
  [0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
52
72
  [0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
53
73
  [0.0.1]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shelldeck
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser
5
5
  Project-URL: Homepage, https://github.com/codejunction/shelldeck
6
6
  Project-URL: Source, https://github.com/codejunction/shelldeck
@@ -196,7 +196,7 @@ sd schedule list|add|run|toggle|delete|logs
196
196
  sd task list|add|move|delete|alarms
197
197
  sd serve [--host H] run the server in the foreground
198
198
  sd stop stop the background server (closes all terminals)
199
- sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
199
+ sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
200
200
  sd login-link emergency: one-time login URL (host only)
201
201
  sd reset-password emergency: forget the password (host only)
202
202
  ```
@@ -221,13 +221,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
221
221
  **`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
222
222
 
223
223
  ```sh
224
- sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
224
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
225
+ sd share --new-link # another one-use link for the running share (a second device)
225
226
  ```
226
227
 
227
- - **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
228
- - **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
228
+ - **Encrypted in transit, not end to end.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
229
+ - **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
230
+ - **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
231
+ - **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
229
232
  - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
230
- - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
233
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
231
234
 
232
235
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
233
236
 
@@ -244,6 +247,8 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
244
247
  - **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
245
248
  - **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
246
249
  - **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
250
+ - **Headers.** Every response forbids framing (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), sniffing and referrers, and API responses are never cached.
251
+ - **Sharing** adds a one-use, 10-minute link, approval on the host and a 12+ character password; see [Remote access](#remote-access).
247
252
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
248
253
  - **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
249
254
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -161,7 +161,7 @@ sd schedule list|add|run|toggle|delete|logs
161
161
  sd task list|add|move|delete|alarms
162
162
  sd serve [--host H] run the server in the foreground
163
163
  sd stop stop the background server (closes all terminals)
164
- sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
164
+ sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
165
165
  sd login-link emergency: one-time login URL (host only)
166
166
  sd reset-password emergency: forget the password (host only)
167
167
  ```
@@ -186,13 +186,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
186
186
  **`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
187
187
 
188
188
  ```sh
189
- sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
189
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
190
+ sd share --new-link # another one-use link for the running share (a second device)
190
191
  ```
191
192
 
192
- - **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
193
- - **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
193
+ - **Encrypted in transit, not end to end.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
194
+ - **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
195
+ - **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
196
+ - **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
194
197
  - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
195
- - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
198
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
196
199
 
197
200
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
198
201
 
@@ -209,6 +212,8 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
209
212
  - **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
210
213
  - **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
211
214
  - **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
215
+ - **Headers.** Every response forbids framing (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), sniffing and referrers, and API responses are never cached.
216
+ - **Sharing** adds a one-use, 10-minute link, approval on the host and a 12+ character password; see [Remote access](#remote-access).
212
217
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
213
218
  - **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
214
219
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shelldeck"
3
- version = "0.0.3"
3
+ version = "0.0.4"
4
4
  description = "Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,9 +20,11 @@ from . import db
20
20
 
21
21
  LOCK_TIMEOUT_SECONDS = 30 * 60
22
22
  MIN_PASSWORD = 8
23
+ SHARE_MIN_PASSWORD = 12 # `sd share` puts the login page on the internet
23
24
  COOKIE = "sd_session"
24
25
  TOKEN_HEADER = "x-shelldeck-token"
25
26
  _PASSWORD_KEY = "auth_password_hash"
27
+ _STRONG_KEY = "auth_password_strong" # "1" when the password is long enough to share
26
28
  _TOUCH_EVERY = 60 # seconds; last_seen writes are throttled
27
29
  _CACHE_TTL = 5 # seconds a session lookup is trusted before re-reading the db
28
30
 
@@ -57,7 +59,20 @@ def has_password() -> bool:
57
59
 
58
60
  def check_password(password: str) -> bool:
59
61
  stored = db.get_setting(_PASSWORD_KEY)
60
- return bool(stored) and _verify_password(password, stored)
62
+ ok = bool(stored) and _verify_password(password, stored)
63
+ if ok:
64
+ _note_strength(password) # passwords set before this flag existed get it at their next login
65
+ return ok
66
+
67
+
68
+ def _note_strength(password: str) -> None:
69
+ strong = "1" if len(password) >= SHARE_MIN_PASSWORD else ""
70
+ if db.get_setting(_STRONG_KEY, "") != strong:
71
+ db.set_setting(_STRONG_KEY, strong)
72
+
73
+
74
+ def strong_password() -> bool:
75
+ return db.get_setting(_STRONG_KEY, "") == "1"
61
76
 
62
77
 
63
78
  def validate_new(password: str, confirm: str) -> str | None:
@@ -71,6 +86,7 @@ def validate_new(password: str, confirm: str) -> str | None:
71
86
 
72
87
  def set_password(password: str) -> None:
73
88
  db.set_setting(_PASSWORD_KEY, _hash_password(password))
89
+ _note_strength(password)
74
90
  _file("setup-code").unlink(missing_ok=True)
75
91
 
76
92
 
@@ -128,6 +144,7 @@ def init_auth() -> str | None:
128
144
  def reset() -> str:
129
145
  """Emergency, host-only: forget the password and every login. Returns the new setup code."""
130
146
  db.delete_setting(_PASSWORD_KEY)
147
+ db.delete_setting(_STRONG_KEY)
131
148
  db.delete_auth_sessions()
132
149
  _file("setup-code").unlink(missing_ok=True)
133
150
  _cache.clear()
@@ -1,10 +1,12 @@
1
1
  import json
2
2
  import os
3
+ import queue
3
4
  import re
4
5
  import shutil
5
6
  import ssl
6
7
  import subprocess
7
8
  import sys
9
+ import threading
8
10
  import time
9
11
  import urllib.error
10
12
  import urllib.request
@@ -308,13 +310,53 @@ def _show_share(url: str) -> None:
308
310
  if sys.stdout.isatty():
309
311
  segno.make(url, error="l").terminal(compact=(sys.stdout.encoding or "").lower().startswith("utf"))
310
312
  typer.echo(f"\n {url}\n")
311
- typer.echo(" Scan or open it on the other device, then log in with your shelldeck password.")
312
- typer.echo(" Without this link the tunnel URL is refused. Ctrl+C stops sharing and signs those browsers out.")
313
+ typer.echo(" Open it on the other device within 10 minutes. It works once; allow the device here, then log in")
314
+ typer.echo(" with your password. Another device: `sd share --new-link`. Ctrl+C stops sharing and signs them out.")
315
+
316
+
317
+ def _watch_share() -> None:
318
+ """Renew the share's lease (the server closes it ~60s after this stops) and ask here before a
319
+ device that opened the link gets in."""
320
+ asks: queue.Queue = queue.Queue()
321
+
322
+ def poll() -> None:
323
+ asked: set[str] = set()
324
+ while True:
325
+ try:
326
+ for p in _api("/api/share").get("pending", []):
327
+ if p["id"] not in asked:
328
+ asked.add(p["id"])
329
+ asks.put(p)
330
+ except typer.Exit:
331
+ pass # server busy or restarting; try again
332
+ time.sleep(3)
333
+
334
+ def ask() -> None:
335
+ while True:
336
+ p = asks.get()
337
+ typer.echo(f"\n A device opened the link: {p['ip']}\n {p['agent'][:100]}")
338
+ allow = input(" Allow it? [y/N] ").strip().lower() in ("y", "yes")
339
+ try:
340
+ _api("/api/share/decide", "POST", {"id": p["id"], "allow": allow})
341
+ typer.echo(" allowed; it can log in with your password now" if allow else " denied")
342
+ except typer.Exit:
343
+ typer.echo(" that request expired; open a new link")
344
+
345
+ for target in (poll, ask):
346
+ threading.Thread(target=target, daemon=True).start()
313
347
 
314
348
 
315
349
  @app.command()
316
- def share():
317
- """Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, QR link, then your password."""
350
+ def share(
351
+ new_link: bool = typer.Option(False, "--new-link", help="Print a fresh one-use link for the running share."),
352
+ ):
353
+ """Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, one-use QR link, host approval, then your password."""
354
+ if new_link:
355
+ if not (_health() == "ok" and _api("/api/share").get("host")):
356
+ typer.echo("not sharing; start with `sd share`", err=True)
357
+ raise typer.Exit(1)
358
+ _show_share(f"https://{_api('/api/share')['host']}" + _api("/api/share/link", "POST", {})["path"])
359
+ return
318
360
  exe = shutil.which("cloudflared")
319
361
  if not exe:
320
362
  hint = "winget install Cloudflare.cloudflared" if sys.platform == "win32" else "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
@@ -324,6 +366,13 @@ def share():
324
366
  if not _api("/api/auth/status").get("has_password"):
325
367
  typer.echo("Set a password first: open shelldeck on this machine (`sd`), then run `sd share` again.", err=True)
326
368
  raise typer.Exit(1)
369
+ if not _api("/api/share").get("strong_password"):
370
+ typer.echo(
371
+ f"Sharing puts the login page on the internet, so it needs a password of {auth.SHARE_MIN_PASSWORD}+ characters.\n"
372
+ "Change it in Settings (or, if it is already that long, log in once), then run `sd share` again.",
373
+ err=True,
374
+ )
375
+ raise typer.Exit(1)
327
376
  # the tunnel ends at our own loopback server, whose certificate (if any) is self-signed
328
377
  cmd = [exe, "tunnel", "--no-autoupdate", "--url", _url()] + (["--no-tls-verify"] if _scheme() == "https" else [])
329
378
  proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, errors="replace")
@@ -335,6 +384,7 @@ def share():
335
384
  elif host and not shared and "Registered tunnel connection" in line:
336
385
  shared = f"https://{host}" + _api("/api/share", "POST", {"host": host})["path"]
337
386
  _show_share(shared)
387
+ _watch_share()
338
388
  except KeyboardInterrupt:
339
389
  pass
340
390
  finally:
@@ -18,7 +18,7 @@ from urllib.parse import quote, urlsplit
18
18
 
19
19
  import uvicorn
20
20
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
21
- from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
21
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
22
22
  from fastapi.staticfiles import StaticFiles
23
23
 
24
24
  from . import auth, db, gitgraph, shells, stats
@@ -68,26 +68,47 @@ def err(code: str, status: int = 400) -> JSONResponse:
68
68
  return JSONResponse({"error": code}, status_code=status)
69
69
 
70
70
 
71
- # `sd share`: the tunnel's public hostname, sha256 of its link token, and the logins made through it
71
+ # `sd share`: the tunnel's public hostname, the sha256 of its one unused link token (None once used),
72
+ # when that link expires, when the CLI last renewed the lease, and one grant per browser that opened
73
+ # a link: sha256(cookie) -> {id, ip, agent, at, state: pending|ok|denied, session}.
72
74
  _share: dict = {}
73
75
  SHARE_COOKIE = "sd_share"
76
+ SHARE_LEASE = 60 # seconds without a CLI heartbeat before the share closes itself
77
+ LINK_TTL = 600 # a link must be opened within 10 minutes, once
78
+ APPROVAL_TTL = 300 # a pending device the host didn't answer is dropped after 5 minutes
74
79
 
75
80
 
76
- def _share_key(token: str | None) -> bool:
77
- return bool(token and _share) and hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), _share["key"])
81
+ def _sha(value: str) -> str:
82
+ return hashlib.sha256(value.encode()).hexdigest()
83
+
84
+
85
+ def _share_on() -> bool:
86
+ """A share exists and its `sd share` process is still renewing it."""
87
+ return bool(_share) and time.time() - _share["seen"] < SHARE_LEASE
88
+
89
+
90
+ def _share_link_ok(token: str) -> bool:
91
+ key = _share.get("link")
92
+ return bool(key) and time.time() < _share["link_until"] and hmac.compare_digest(_sha(token), key)
93
+
94
+
95
+ def _grant(conn) -> dict | None:
96
+ cookie = conn.cookies.get(SHARE_COOKIE)
97
+ return _share.get("grants", {}).get(_sha(cookie)) if cookie and _share else None
78
98
 
79
99
 
80
100
  def _via_share(conn) -> bool:
81
- return bool(_share) and conn.headers.get("host", "").rsplit(":", 1)[0] == _share["host"]
101
+ return _share_on() and conn.headers.get("host", "").rsplit(":", 1)[0] == _share["host"]
82
102
 
83
103
 
84
104
  def _trusted(conn) -> bool:
85
105
  """Same-origin check. Browsers always send Origin cross-site; the CLI sends none.
86
- The share tunnel's host also needs the cookie its link sets (or to be opening that link)."""
106
+ On the share tunnel's host only /share/ pages are open; the rest needs a host-approved grant."""
87
107
  headers = conn.headers
88
108
  host = headers.get("host", "")
89
109
  if _via_share(conn):
90
- if not (conn.url.path.startswith("/share/") or _share_key(conn.cookies.get(SHARE_COOKIE))):
110
+ g = _grant(conn)
111
+ if not (conn.url.path.startswith("/share/") or (g and g["state"] == "ok")):
91
112
  return False
92
113
  elif ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
93
114
  return False
@@ -127,9 +148,11 @@ async def lifespan(app: FastAPI):
127
148
  manager.store = db.config_dir() / "scrollback"
128
149
  manager.prune({s["id"] for s in db.list_sessions()})
129
150
  saver = asyncio.create_task(_save_scrollback())
151
+ reaper = asyncio.create_task(_share_reaper())
130
152
  log.info("shelldeck %s started", VERSION)
131
153
  yield
132
154
  saver.cancel()
155
+ reaper.cancel()
133
156
  stats.stop()
134
157
  sched.shutdown()
135
158
  for task in list(readers.values()):
@@ -149,6 +172,15 @@ async def _save_scrollback() -> None:
149
172
  log.warning("saving scrollback failed", exc_info=True)
150
173
 
151
174
 
175
+ async def _share_reaper() -> None:
176
+ """Close a share whose `sd share` stopped renewing it (killed, window closed, crashed)."""
177
+ while True:
178
+ await asyncio.sleep(10)
179
+ if _share and not _share_on():
180
+ log.info("share lease expired; closing it")
181
+ await _share_stop()
182
+
183
+
152
184
  class RevalidatedStaticFiles(StaticFiles):
153
185
  """Browsers must revalidate (a cheap 304), or they keep running old JS after an upgrade."""
154
186
 
@@ -199,8 +231,24 @@ def _denied() -> JSONResponse:
199
231
  return err("locked" if auth.has_password() else "setup_required", 401)
200
232
 
201
233
 
234
+ SECURITY_HEADERS = {
235
+ "X-Frame-Options": "DENY",
236
+ "Content-Security-Policy": "frame-ancestors 'none'",
237
+ "X-Content-Type-Options": "nosniff",
238
+ "Referrer-Policy": "no-referrer",
239
+ }
240
+
241
+
202
242
  @app.middleware("http")
203
243
  async def guard(request: Request, call_next):
244
+ response = await _guarded(request, call_next)
245
+ response.headers.update(SECURITY_HEADERS)
246
+ if request.url.path.startswith("/api/"):
247
+ response.headers["Cache-Control"] = "no-store" # terminal output, history, devices
248
+ return response
249
+
250
+
251
+ async def _guarded(request: Request, call_next):
204
252
  if not _trusted(request):
205
253
  return err("forbidden_origin", 403)
206
254
  path = request.url.path
@@ -262,6 +310,8 @@ async def _login_response(request: Request, body: dict, res=None):
262
310
  token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}", _via(request))
263
311
  h = auth.session_hash(token)
264
312
  _active["h"] = h
313
+ if _via_share(request) and (g := _grant(request)):
314
+ g["session"] = h # revoking this login also voids the grant
265
315
  await _close_session_sockets(lambda owner: owner != h, IN_USE)
266
316
  res = res or JSONResponse(body)
267
317
  res.set_cookie(
@@ -342,17 +392,64 @@ async def auth_login_link(request: Request):
342
392
 
343
393
  @app.post("/api/share")
344
394
  async def share_start(request: Request, payload: dict):
345
- """Host CLI only: open the gate for an `sd share` tunnel host; returns its one link."""
395
+ """Host CLI only: open the gate for an `sd share` tunnel host; returns its first link."""
346
396
  if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
347
397
  return err("host_cli_only", 403)
398
+ if not auth.strong_password():
399
+ return err("weak_password", 409)
348
400
  host = str(payload.get("host", "")).lower()
349
401
  if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", host):
350
402
  return err("invalid_host")
351
403
  await _share_stop()
352
- token = secrets.token_urlsafe(32)
353
- _share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest())
404
+ _share.update(host=host, seen=time.time(), grants={})
354
405
  log.info("sharing via %s", host)
355
- return {"path": f"/share/{token}"}
406
+ return {"path": _new_share_link()}
407
+
408
+
409
+ def _new_share_link() -> str:
410
+ """One link at a time: a new one voids the unused old one."""
411
+ token = secrets.token_urlsafe(32)
412
+ _share.update(link=_sha(token), link_until=time.time() + LINK_TTL)
413
+ return f"/share/{token}"
414
+
415
+
416
+ @app.get("/api/share")
417
+ async def share_state(request: Request):
418
+ """Host CLI heartbeat: renews the lease and lists devices waiting for approval."""
419
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
420
+ return err("host_cli_only", 403)
421
+ if not _share:
422
+ return {"host": None, "strong_password": auth.strong_password()}
423
+ now = time.time()
424
+ _share["seen"] = now
425
+ grants = _share["grants"]
426
+ for k in [k for k, g in grants.items() if g["state"] == "pending" and now - g["at"] > APPROVAL_TTL]:
427
+ del grants[k]
428
+ pending = [{"id": g["id"], "ip": g["ip"], "agent": g["agent"]} for g in grants.values() if g["state"] == "pending"]
429
+ return {"host": _share["host"], "pending": pending, "strong_password": auth.strong_password()}
430
+
431
+
432
+ @app.post("/api/share/link")
433
+ async def share_link(request: Request):
434
+ """Host CLI only: a fresh link for another device on the running share."""
435
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
436
+ return err("host_cli_only", 403)
437
+ if not _share_on():
438
+ return err("not_sharing", 404)
439
+ return {"path": _new_share_link()}
440
+
441
+
442
+ @app.post("/api/share/decide")
443
+ async def share_decide(request: Request, payload: dict):
444
+ """Host CLI only: allow or deny a device that opened a link."""
445
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
446
+ return err("host_cli_only", 403)
447
+ g = next((g for g in _share.get("grants", {}).values() if g["id"] == payload.get("id")), None)
448
+ if not g or g["state"] != "pending":
449
+ return err("not_found", 404)
450
+ g["state"] = "ok" if payload.get("allow") is True else "denied"
451
+ log.info("share device %s from %s %s", g["id"], g["ip"], "allowed" if g["state"] == "ok" else "denied")
452
+ return {"state": g["state"]}
356
453
 
357
454
 
358
455
  @app.delete("/api/share")
@@ -372,13 +469,57 @@ async def _share_stop() -> None:
372
469
  await _revoke(gone)
373
470
 
374
471
 
472
+ _WAIT_PAGE = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
473
+ <title>shelldeck</title><meta name="referrer" content="no-referrer">
474
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#09090b;color:#e4e4e7;
475
+ font:16px/1.5 Inter,"Segoe UI",system-ui,sans-serif;text-align:center;padding:24px;box-sizing:border-box}
476
+ p{max-width:28em;margin:.4em auto}b{color:#a78bfa}.m{color:#a1a1aa;font-size:14px}</style>
477
+ <main><p><b>shelldeck</b></p><p id="s">Waiting for the host to allow this device…</p>
478
+ <p class="m" id="m">Answer the prompt in the <code>sd share</code> window on the host.</p></main>
479
+ <script>
480
+ const say = (s, m) => { document.getElementById("s").textContent = s; document.getElementById("m").textContent = m; };
481
+ async function poll() {
482
+ let st = "none";
483
+ try { st = (await (await fetch("/share/status", { cache: "no-store" })).json()).state; } catch {}
484
+ if (st === "ok") return location.replace("/");
485
+ if (st === "denied") return say("The host denied this device.", "Ask for a new link if that was a mistake.");
486
+ if (st !== "pending") return say("This request expired.", "Ask the host for a new link.");
487
+ setTimeout(poll, 2000);
488
+ }
489
+ poll();
490
+ </script>"""
491
+
492
+
493
+ def _share_page(body: str, status: int = 200):
494
+ return HTMLResponse(body, status_code=status, headers={"Cache-Control": "no-store"})
495
+
496
+
497
+ @app.get("/share/status")
498
+ async def share_status(request: Request):
499
+ """The waiting page's poll: pending, ok, denied, or none."""
500
+ g = _grant(request) if _via_share(request) else None
501
+ return JSONResponse({"state": g["state"] if g else "none"}, headers={"Cache-Control": "no-store"})
502
+
503
+
375
504
  @app.get("/share/{token}")
376
505
  async def share_open(request: Request, token: str):
377
- """The QR link: sets the tunnel cookie, then the usual password login takes over."""
378
- if not (_via_share(request) and _share_key(token)):
506
+ """The QR link: works once, within LINK_TTL. It makes a pending grant that the host must allow in
507
+ the `sd share` window; after that the usual password login takes over."""
508
+ if not _via_share(request):
379
509
  return err("link_expired", 403)
380
- res = RedirectResponse("/", status_code=303) # drops the token from the address bar
381
- res.set_cookie(SHARE_COOKIE, token, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
510
+ if g := _grant(request): # this browser already opened a link (e.g. a reload)
511
+ return RedirectResponse("/", status_code=303) if g["state"] == "ok" else _share_page(_WAIT_PAGE)
512
+ if not _share_link_ok(token):
513
+ return _share_page(_WAIT_PAGE.replace("poll();", 'say("This link has expired or was already used.", "Ask the host for a new one.");'), 403)
514
+ _share["link"] = None # one use
515
+ cookie = secrets.token_urlsafe(32)
516
+ _share["grants"][_sha(cookie)] = {
517
+ "id": secrets.token_hex(4), "ip": _client(request), "agent": request.headers.get("user-agent", "")[:200],
518
+ "at": time.time(), "state": "pending", "session": None,
519
+ }
520
+ log.info("share link opened from %s; waiting for the host", _client(request))
521
+ res = _share_page(_WAIT_PAGE)
522
+ res.set_cookie(SHARE_COOKIE, cookie, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
382
523
  return res
383
524
 
384
525
 
@@ -420,7 +561,7 @@ async def devices(request: Request):
420
561
  "created_at": r["created_at"], "last_seen": r["last_seen"],
421
562
  "active": h == _active["h"], "current": h == request.state.session, "sockets": online.get(h, 0),
422
563
  })
423
- return {"devices": rows, "share": _share.get("host")}
564
+ return {"devices": rows, "share": _share["host"] if _share_on() else None}
424
565
 
425
566
 
426
567
  @app.delete("/api/devices/{device_id}")
@@ -432,6 +573,9 @@ async def revoke_device(request: Request, device_id: str):
432
573
 
433
574
 
434
575
  async def _revoke(hashes: list[str]) -> dict:
576
+ grants = _share.get("grants", {})
577
+ for k in [k for k, g in grants.items() if g["session"] in hashes]:
578
+ del grants[k] # a revoked share device needs a new link and approval
435
579
  for h in hashes:
436
580
  auth.end_session(h)
437
581
  if _active["h"] == h:
@@ -124,13 +124,22 @@ def test_share_waits_for_tunnel_then_prints_link_and_cleans_up(monkeypatch, caps
124
124
 
125
125
  def api(path, method="GET", payload=None):
126
126
  calls.append((method, path))
127
- return {"has_password": True} if path == "/api/auth/status" else {"path": "/share/tok"}
127
+ return {"has_password": True, "strong_password": True, "path": "/share/tok"}
128
128
 
129
129
  monkeypatch.setattr(cli.shutil, "which", lambda name: "cloudflared")
130
130
  monkeypatch.setattr(cli, "ensure_server", lambda: None)
131
131
  monkeypatch.setattr(cli, "_health", lambda: "ok")
132
132
  monkeypatch.setattr(cli, "_api", api)
133
+ monkeypatch.setattr(cli, "_watch_share", lambda: calls.append("watch"))
133
134
  monkeypatch.setattr(cli.subprocess, "Popen", lambda argv, **kw: calls.append(argv[1:4]) or FakeTunnel())
134
- cli.share()
135
+ cli.share(new_link=False)
135
136
  assert "https://abc-def.trycloudflare.com/share/tok" in capsys.readouterr().out
136
- assert calls[1:] == [["tunnel", "--no-autoupdate", "--url"], ("POST", "/api/share"), "terminate", ("DELETE", "/api/share")]
137
+ assert calls[2:] == [["tunnel", "--no-autoupdate", "--url"], ("POST", "/api/share"), "watch", "terminate", ("DELETE", "/api/share")]
138
+
139
+
140
+ def test_share_refuses_a_short_password(monkeypatch):
141
+ monkeypatch.setattr(cli.shutil, "which", lambda name: "cloudflared")
142
+ monkeypatch.setattr(cli, "ensure_server", lambda: None)
143
+ monkeypatch.setattr(cli, "_api", lambda path, *a: {"has_password": True, "strong_password": False})
144
+ r = runner.invoke(cli.app, ["share"])
145
+ assert r.exit_code == 1 and "12+ characters" in r.output
@@ -369,28 +369,48 @@ def test_projects_get_distinct_colors(client, tmp_path):
369
369
  assert client.put("/api/settings", json={"layout_mode": "grid"}).status_code == 400
370
370
 
371
371
 
372
- def test_share_gate_needs_link_then_password(browser):
373
- b = browser
374
- b.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
372
+ def _share_setup(b, password="long enough pass"):
373
+ b.post("/api/auth/setup", json={"password": password, "confirm": password})
375
374
  cli = {"X-Shelldeck-Token": auth.read_cli_token()}
376
375
  tunnel = {"host": "abc-def.trycloudflare.com", "cf-connecting-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9"}
376
+ return cli, tunnel
377
+
378
+
379
+ def _open_link(b, path, tunnel):
380
+ r = b.get(path, headers=tunnel)
381
+ return r, {**tunnel, "cookie": r.headers["set-cookie"].split(";")[0]}
382
+
383
+
384
+ def _allow(b, cli, allow=True):
385
+ [p] = b.get("/api/share", headers=cli).json()["pending"]
386
+ return b.post("/api/share/decide", json={"id": p["id"], "allow": allow}, headers=cli)
387
+
388
+
389
+ def test_share_gate_needs_link_approval_then_password(browser):
390
+ b = browser
391
+ cli, tunnel = _share_setup(b)
377
392
  assert b.get("/", headers=tunnel).status_code == 403 # not shared
378
393
  assert b.post("/api/share", json={"host": tunnel["host"]}).status_code == 403 # browsers can't open it
394
+ assert b.get("/api/share").status_code == 403
379
395
  assert b.post("/api/share", json={"host": "bad host"}, headers=cli).status_code == 400
380
396
  path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
381
397
 
382
- # the bare tunnel URL and a wrong token get nothing; the link sets the gate cookie
398
+ # the bare tunnel URL and a wrong token get nothing; the link waits for the host
383
399
  assert b.get("/", headers=tunnel).status_code == 403
384
400
  assert b.get("/share/wrong", headers=tunnel).status_code == 403
385
- r = b.get(path, headers=tunnel, follow_redirects=False)
386
- assert r.status_code == 303 and r.headers["location"] == "/" and "secure" in r.headers["set-cookie"].lower()
387
- gate = {**tunnel, "cookie": f"{server.SHARE_COOKIE}={path.rsplit('/', 1)[1]}"}
401
+ r, gate = _open_link(b, path, tunnel)
402
+ assert r.status_code == 200 and "secure" in r.headers["set-cookie"].lower() and "Waiting" in r.text
403
+ assert b.get("/", headers=gate).status_code == 403 # pending
404
+ assert b.get("/share/status", headers=gate).json() == {"state": "pending"}
405
+ assert b.get(path, headers=tunnel).status_code == 403 # the link worked once
406
+ assert _allow(b, cli).json() == {"state": "ok"}
407
+ assert b.get("/share/status", headers=gate).json() == {"state": "ok"}
388
408
  assert b.get("/", headers=gate).status_code == 200
389
409
 
390
410
  # the password is still required, and tunnel visitors are remote
391
411
  assert b.get("/api/projects", headers=gate).json() == {"error": "locked"}
392
412
  assert b.post("/api/auth/login-link", headers=gate).status_code == 403
393
- r = b.post("/api/auth/login", json={"password": "longenough"}, headers={**gate, "origin": "https://" + tunnel["host"]})
413
+ r = b.post("/api/auth/login", json={"password": "long enough pass"}, headers={**gate, "origin": "https://" + tunnel["host"]})
394
414
  assert r.status_code == 200
395
415
  login = r.headers["set-cookie"].split(";")[0]
396
416
  authed = {**tunnel, "cookie": f"{gate['cookie']}; {login}"}
@@ -405,6 +425,64 @@ def test_share_gate_needs_link_then_password(browser):
405
425
  assert b.get("/api/projects", headers={"cookie": login}).status_code == 401
406
426
 
407
427
 
428
+ def test_share_denied_expired_and_new_link(browser, monkeypatch):
429
+ b = browser
430
+ cli, tunnel = _share_setup(b)
431
+ path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
432
+ _, gate = _open_link(b, path, tunnel)
433
+ assert _allow(b, cli, allow=False).json() == {"state": "denied"}
434
+ assert b.get("/", headers=gate).status_code == 403
435
+ assert b.get("/share/status", headers=gate).json() == {"state": "denied"}
436
+
437
+ # a new link replaces the old one, and expires unopened
438
+ old = b.post("/api/share/link", headers=cli).json()["path"]
439
+ new = b.post("/api/share/link", headers=cli).json()["path"]
440
+ assert b.get(old, headers=tunnel).status_code == 403
441
+ now = time.time()
442
+ monkeypatch.setattr(server.time, "time", lambda: now + server.LINK_TTL + 1)
443
+ b.get("/api/share", headers=cli) # heartbeat keeps the share itself alive
444
+ assert b.get(new, headers=tunnel).status_code == 403
445
+
446
+
447
+ def test_share_lease_expires_without_heartbeat(browser, monkeypatch):
448
+ b = browser
449
+ cli, tunnel = _share_setup(b)
450
+ path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
451
+ _, gate = _open_link(b, path, tunnel)
452
+ _allow(b, cli)
453
+ assert b.get("/", headers=gate).status_code == 200
454
+ now = time.time()
455
+ monkeypatch.setattr(server.time, "time", lambda: now + server.SHARE_LEASE + 1)
456
+ assert b.get("/", headers=gate).status_code == 403 # `sd share` died: the tunnel host is refused
457
+ assert b.get("/api/devices").json()["share"] is None
458
+
459
+
460
+ def test_share_revoked_device_loses_its_grant(browser):
461
+ b = browser
462
+ cli, tunnel = _share_setup(b)
463
+ path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
464
+ _, gate = _open_link(b, path, tunnel)
465
+ _allow(b, cli)
466
+ phone = TestClient(server.app)
467
+ r = phone.post("/api/auth/login", json={"password": "long enough pass"}, headers={**gate, "origin": "https://" + tunnel["host"]})
468
+ h = auth.session_hash(r.cookies[auth.COOKIE])
469
+ assert b.post("/api/auth/login", json={"password": "long enough pass"}).status_code == 200 # host takes over
470
+ assert b.delete(f"/api/devices/{h}").json() == {"revoked": 1}
471
+ assert b.get("/", headers=gate).status_code == 403
472
+
473
+
474
+ def test_share_needs_a_long_password(browser):
475
+ cli, tunnel = _share_setup(browser, password="short123")
476
+ assert browser.get("/api/share", headers=cli).json()["strong_password"] is False
477
+ assert browser.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json() == {"error": "weak_password"}
478
+
479
+
480
+ def test_security_headers(browser):
481
+ r = browser.get("/api/auth/status")
482
+ assert r.headers["x-frame-options"] == "DENY" and r.headers["cache-control"] == "no-store"
483
+ assert "frame-ancestors 'none'" in browser.get("/").headers["content-security-policy"]
484
+
485
+
408
486
  def test_devices_one_active_takeover_and_revoke(browser, tmp_path):
409
487
  a = browser
410
488
  a.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes