Harden loopback HTTP and document AMO unlisted signing.
This commit is contained in:
@@ -113,6 +113,15 @@ You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified
|
|||||||
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO unlisted |
|
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO unlisted |
|
||||||
| Native host not found | Installer not run | `tools/install-native-host.ps1` |
|
| Native host not found | Installer not run | `tools/install-native-host.ps1` |
|
||||||
|
|
||||||
|
## Security boundaries
|
||||||
|
|
||||||
|
- The extension does **not** listen on `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher.
|
||||||
|
- The native host manifest `allowed_extensions` is pinned to `firefox-agent-bridge@easygoingaming.com`. A different add-on cannot `connectNative` to this host.
|
||||||
|
- HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected (pages cannot drive the API). `Host` must be `127.0.0.1:<port>` or `localhost:<port>`.
|
||||||
|
- A same-user process that can read `%LOCALAPPDATA%\firefox-agent-bridge\token` has the same power as this API. That is intentional for local agents. Do not copy the token into git, chat, or a world-readable file.
|
||||||
|
- Another add-on that already has the `bookmarks` permission does not need this bridge — Firefox already gave it Places. This project does not increase that add-on's capability.
|
||||||
|
- Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review.
|
||||||
|
|
||||||
## What this project is not
|
## What this project is not
|
||||||
|
|
||||||
- Not a bidirectional sync implementation
|
- Not a bidirectional sync implementation
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite`
|
|||||||
- **Load Temporary Add-on**
|
- **Load Temporary Add-on**
|
||||||
- pick `extension\manifest.json`
|
- pick `extension\manifest.json`
|
||||||
|
|
||||||
Permanent install later = AMO-signed (unlisted is enough).
|
Permanent install: Mozilla-signed `.xpi` — [docs/signing.md](docs/signing.md). Start with `--channel=unlisted`.
|
||||||
|
|
||||||
3. Leave Firefox open. The extension starts the host; `GET http://127.0.0.1:17634/health` should return JSON.
|
3. Leave Firefox open. The extension starts the host; `GET http://127.0.0.1:17634/health` should return JSON.
|
||||||
|
|
||||||
@@ -52,9 +52,10 @@ See [AGENTS.md](AGENTS.md) for the method list, HTTP surface, and failure modes.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Binds **127.0.0.1 only**.
|
- Binds **127.0.0.1 only**. Browser `Origin` headers are rejected; `Host` must be loopback.
|
||||||
- Every mutating call (and most reads) needs `Authorization: Bearer <token>`.
|
- Every mutating call (and most reads) needs `Authorization: Bearer <token>`.
|
||||||
- Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, or tabs in v0.1.
|
- Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, tabs, or `onMessageExternal` in v0.1.
|
||||||
|
- Another add-on with `bookmarks` already has Places; this bridge does not give it a new path in. Same-user processes that steal the token do — that is the agent contract.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Signing for Firefox Release
|
||||||
|
|
||||||
|
Firefox Release and Beta will only keep an add-on that Mozilla has signed. Temporary add-ons from `about:debugging` die on restart. Developer Edition / Nightly can load unsigned builds if `xpinstall.signatures.required` is false — do not rely on that for this workstation.
|
||||||
|
|
||||||
|
Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even when the add-on is **not** listed in the store.
|
||||||
|
|
||||||
|
## 1. AMO developer account
|
||||||
|
|
||||||
|
1. Register at [addons.mozilla.org](https://addons.mozilla.org/) (Mozilla account).
|
||||||
|
2. Open [API credentials](https://addons.mozilla.org/developers/addon/api/key/).
|
||||||
|
3. Generate a JWT **issuer** (`user:…`) and **secret**.
|
||||||
|
4. Store both in Vaultwarden. Never commit them. Env names `WEB_EXT_API_KEY` / `WEB_EXT_API_SECRET` (or `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` if you prefer wrappers).
|
||||||
|
|
||||||
|
## 2. Choose a channel
|
||||||
|
|
||||||
|
| Channel | What you get |
|
||||||
|
|---------|----------------|
|
||||||
|
| **unlisted** | Signed `.xpi` for self-install. Not on the AMO store. Enough to survive Firefox restarts. **Start here.** |
|
||||||
|
| **listed** | Public AMO listing, automatic updates via Firefox. Needs listing metadata, review, and Add-on Policy compliance. Do this when you want strangers to find it. |
|
||||||
|
|
||||||
|
Unlisted submissions can still be pulled for manual review. Native messaging is allowed; be ready to explain that the HTTP listener is the **native host**, bind is loopback-only, and the extension does not accept `onMessageExternal`.
|
||||||
|
|
||||||
|
## 3. Sign (unlisted)
|
||||||
|
|
||||||
|
From this repo, with Node + `web-ext` 8+:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install -g web-ext
|
||||||
|
cd extension
|
||||||
|
web-ext sign --channel=unlisted --api-key $env:WEB_EXT_API_KEY --api-secret $env:WEB_EXT_API_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
The gecko id in `manifest.json` (`firefox-agent-bridge@easygoingaming.com`) must stay stable. Bump `version` for every new sign.
|
||||||
|
|
||||||
|
`web-ext` writes a signed `.xpi` under `web-ext-artifacts/`. Install it in Firefox: the `.xpi` file, or `about:addons` → gear → Install Add-on From File.
|
||||||
|
|
||||||
|
Keep the native host registered (`tools/install-native-host.ps1`). Signing replaces only the extension; the host is unchanged.
|
||||||
|
|
||||||
|
## 4. Updates
|
||||||
|
|
||||||
|
Bump `version`, sign again on the same channel and id. For unlisted self-distribution, [updates](https://extensionworkshop.com/documentation/manage/updating-your-extension/) need an `update_url` in `browser_specific_settings.gecko` if you want Firefox to auto-fetch. Until that exists, drop in a new `.xpi` by hand.
|
||||||
|
|
||||||
|
## 5. Listed (later)
|
||||||
|
|
||||||
|
`web-ext sign --channel=listed` plus an AMO metadata JSON (name, summary, license MIT). Expect listing copy that says: local scripts only, loopback HTTP, bearer token, bookmarks allowlist. Do not claim it is a general Firefox remote-control tool.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio."""
|
"""Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
@@ -21,6 +22,8 @@ if sys.platform == "win32":
|
|||||||
|
|
||||||
HOST = "127.0.0.1"
|
HOST = "127.0.0.1"
|
||||||
PORT = int(os.environ.get("FAB_PORT", "17634"))
|
PORT = int(os.environ.get("FAB_PORT", "17634"))
|
||||||
|
MAX_BODY = 256 * 1024
|
||||||
|
ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"}
|
||||||
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
|
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
|
||||||
TOKEN_PATH = STATE_DIR / "token"
|
TOKEN_PATH = STATE_DIR / "token"
|
||||||
STDIN_LOCK = threading.Lock()
|
STDIN_LOCK = threading.Lock()
|
||||||
@@ -91,20 +94,36 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _gate_ok(self) -> bool:
|
||||||
|
"""Block browser-origin calls and non-loopback Host headers."""
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if origin:
|
||||||
|
self._send(403, {"ok": False, "error": "browser origin not allowed"})
|
||||||
|
return False
|
||||||
|
host = (self.headers.get("Host") or "").split("%")[0].lower()
|
||||||
|
if host not in ALLOWED_HOSTS:
|
||||||
|
self._send(403, {"ok": False, "error": "host not allowed"})
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _auth_ok(self) -> bool:
|
def _auth_ok(self) -> bool:
|
||||||
|
if not self._gate_ok():
|
||||||
|
return False
|
||||||
token = read_token()
|
token = read_token()
|
||||||
if not token:
|
if not token:
|
||||||
self._send(500, {"ok": False, "error": "bridge token missing; run install-native-host.ps1"})
|
self._send(500, {"ok": False, "error": "bridge token missing; run install-native-host.ps1"})
|
||||||
return False
|
return False
|
||||||
header = self.headers.get("Authorization", "")
|
header = self.headers.get("Authorization", "")
|
||||||
got = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
got = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
||||||
if got != token:
|
if not hmac.compare_digest(got, token):
|
||||||
self._send(401, {"ok": False, "error": "missing or invalid bearer token"})
|
self._send(401, {"ok": False, "error": "missing or invalid bearer token"})
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _json_body(self) -> dict[str, Any]:
|
def _json_body(self) -> dict[str, Any]:
|
||||||
length = int(self.headers.get("Content-Length") or "0")
|
length = int(self.headers.get("Content-Length") or "0")
|
||||||
|
if length > MAX_BODY:
|
||||||
|
raise ValueError("request body too large")
|
||||||
if length <= 0:
|
if length <= 0:
|
||||||
return {}
|
return {}
|
||||||
raw = self.rfile.read(length)
|
raw = self.rfile.read(length)
|
||||||
@@ -118,6 +137,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
if parsed.path == "/health":
|
if parsed.path == "/health":
|
||||||
|
if not self._gate_ok():
|
||||||
|
return
|
||||||
self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT})
|
self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT})
|
||||||
return
|
return
|
||||||
if not self._auth_ok():
|
if not self._auth_ok():
|
||||||
|
|||||||
Reference in New Issue
Block a user