foxlend 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +330 -2
- package/dist/browser.d.ts +129 -0
- package/dist/browser.js +17 -0
- package/dist/cookies.d.ts +62 -0
- package/dist/cookies.js +75 -0
- package/dist/egress.d.ts +32 -0
- package/dist/egress.js +65 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +9 -0
- package/dist/foxlend.d.ts +41 -0
- package/dist/foxlend.js +49 -0
- package/dist/guard.d.ts +28 -0
- package/dist/guard.js +40 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/lend.d.ts +44 -0
- package/dist/lend.js +170 -0
- package/dist/revoke.d.ts +15 -0
- package/dist/revoke.js +60 -0
- package/dist/site.d.ts +32 -0
- package/dist/site.js +64 -0
- package/dist/state.d.ts +43 -0
- package/dist/state.js +49 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pooria Arab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,331 @@
|
|
|
1
|
-
#
|
|
1
|
+
# foxlend
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">Lend one login to an AI agent in its own Firefox container, and take it back.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/pooriaarab/foxlend/actions"><img src="https://github.com/pooriaarab/foxlend/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
|
|
7
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License MIT"/></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
An AI agent in your browser can use every site you are logged in to. foxlend
|
|
11
|
+
gives it one. It makes a new Firefox container for the agent, copies the
|
|
12
|
+
cookies of one site into it, and opens the agent's tab there. Pages in that
|
|
13
|
+
container can reach only the lent site and the hosts that you allow. When
|
|
14
|
+
you revoke the loan, or when its time is up, foxlend closes the agent's tabs
|
|
15
|
+
and removes the container with its cookies and site data. Your own tabs stay
|
|
16
|
+
logged in.
|
|
17
|
+
|
|
18
|
+
foxlend runs in a Firefox extension (Firefox 153 or later, desktop). It
|
|
19
|
+
depends on [foxgate](https://github.com/pooriaarab/foxgate), and each loan
|
|
20
|
+
adds a foxgate grant for the same hosts.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm i foxlend
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Example
|
|
29
|
+
|
|
30
|
+
Put this in the background script of your extension. The permissions are in
|
|
31
|
+
[Firefox APIs used](#firefox-apis-used).
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { createFoxgate } from "foxgate";
|
|
35
|
+
import { createFoxlend, withDefaultRule } from "foxlend";
|
|
36
|
+
|
|
37
|
+
// Run both at the top level, so Firefox can wake the page for a request or an alarm.
|
|
38
|
+
const publicSuffix = withDefaultRule(browser.publicSuffix);
|
|
39
|
+
const { gate, host } = createFoxgate({ tools: { read_page: "read" }, publicSuffix });
|
|
40
|
+
const lender = createFoxlend({ browser, host, publicSuffix });
|
|
41
|
+
lender.onBlocked.addListener((b) => console.log("blocked", b.type, b.url));
|
|
42
|
+
|
|
43
|
+
async function lendForFifteenMinutes() {
|
|
44
|
+
const loan = await lender.lend({ domain: "www.example.com", scope: "read", ttlMs: 15 * 60_000 });
|
|
45
|
+
console.log(loan.containerName); // "Agent · example.com"
|
|
46
|
+
const action = { tool: "read_page", args: {}, domain: "www.example.com", scope: "read" };
|
|
47
|
+
console.log((await gate.check(action)).decision); // "allow"
|
|
48
|
+
await lender.revoke(loan); // the tab, the cookies, the container, and the grant are gone
|
|
49
|
+
}
|
|
50
|
+
lendForFifteenMinutes();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Use cases
|
|
54
|
+
|
|
55
|
+
| Who | What they build | How foxlend helps |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| A person who lets an agent book travel | An agent that books a flight with one airline account | Lend the airline site for 30 minutes. The agent cannot open your email or your bank, and a page cannot send your data to other hosts. |
|
|
58
|
+
| A caregiver | Help for a parent with a pharmacy or a utility account | Lend that one login to an agent for a task. Revoke it when the task is done. The parent's other sessions are not shared. |
|
|
59
|
+
| A freelancer | One container for each client account, for example each client's CMS | Each loan is a separate container with a separate cookie jar and allow list. Revoke one client without a change to the others. |
|
|
60
|
+
| A QA engineer | Agent tests that run with a test account on staging | Log in once in your own tab. Lend the staging host to each test run with `match: "host"` and a short time limit. The test cannot reach other hosts, such as production. |
|
|
61
|
+
| A researcher or analyst | Read-only research in their own accounts (a bank export, a reading list) | Lend with the `read` scope. The foxgate grant gives read tools only. The allow list stops a page that tries to send the data out. |
|
|
62
|
+
| A browser agent author (for example foxmate) | A personal agent that runs in the user's own Firefox | The agent asks for a login. The user lends it from the sidebar and sees each blocked request. |
|
|
63
|
+
| A security tester | A test page for prompt-injection data theft | The E2E test in this repo is a working example: a hidden injection tries ten ways to send data, and foxlend stops each one. |
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
```mermaid
|
|
68
|
+
flowchart LR
|
|
69
|
+
L["lend(domain, scope, ttlMs, allow)"] --> R[Write the loan record]
|
|
70
|
+
R --> C["New container: Agent · site, purple, fingerprint"]
|
|
71
|
+
C --> K[Copy the site's cookies, expiry capped at the loan end]
|
|
72
|
+
K --> G[Add a foxgate grant for the same hosts]
|
|
73
|
+
G --> T[Open the task tab in the container]
|
|
74
|
+
T --> A{Request from the container}
|
|
75
|
+
A -- host on the allow list --> P[Pass]
|
|
76
|
+
A -- any other host --> B[Cancel and report on onBlocked]
|
|
77
|
+
T --> V["revoke(), the TTL alarm, or the start sweep"]
|
|
78
|
+
V --> X[Close tabs, clear data, remove the container, revoke the grant]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
1. `lend` refuses the loan unless the extension has access to all sites,
|
|
82
|
+
because without it the guard cannot see the loan's requests. If you
|
|
83
|
+
remove that access later in `about:addons`, foxlend revokes every loan.
|
|
84
|
+
Then `lend` finds the site of the domain (the registrable domain,
|
|
85
|
+
eTLD+1) with `browser.publicSuffix`. The loan allows the site, its subdomains, and the
|
|
86
|
+
hosts in `allow`.
|
|
87
|
+
2. It writes the loan record first, so a crash cannot leave a container that
|
|
88
|
+
no record knows about.
|
|
89
|
+
3. It creates a container and copies the cookies of that site from your
|
|
90
|
+
default container. It keeps `HttpOnly`, `Secure`, `SameSite`, the path,
|
|
91
|
+
host-only, and the partition key (Total Cookie Protection). Each copy
|
|
92
|
+
expires at the loan end. foxlend never writes to your default container.
|
|
93
|
+
4. It adds a foxgate grant with the same hosts and the same end time. Your
|
|
94
|
+
agent loop checks each action with that grant.
|
|
95
|
+
5. It opens the task tab in the container. With `hidden: true`, it hides the
|
|
96
|
+
tab from the tab strip.
|
|
97
|
+
6. Two guard layers judge each request from the container. A blocking
|
|
98
|
+
`webRequest.onBeforeRequest` listener cancels a request to any host that
|
|
99
|
+
is not on the list. A `proxy.onRequest` listener sends the same requests
|
|
100
|
+
to a SOCKS proxy that does not exist. That layer also stops a
|
|
101
|
+
`<link rel="preconnect">`, which `webRequest` never sees.
|
|
102
|
+
7. While a loan is active, foxlend turns off two settings for all of
|
|
103
|
+
Firefox: network prediction (DNS prefetch and link prefetch) and WebRTC.
|
|
104
|
+
Neither guard layer sees DNS prefetch or WebRTC traffic. If foxlend
|
|
105
|
+
cannot turn a setting off, for example because another extension
|
|
106
|
+
controls it, `lend` refuses the loan. foxlend gives the settings back
|
|
107
|
+
after the last loan.
|
|
108
|
+
8. `revoke` blocks every request from the container first. Then it closes
|
|
109
|
+
the tabs, clears the cookies, local storage, and IndexedDB of the
|
|
110
|
+
container, removes the container, and revokes the grant. An alarm runs
|
|
111
|
+
the same revoke at the loan end. When Firefox starts, foxlend revokes the
|
|
112
|
+
loans whose time is over.
|
|
113
|
+
|
|
114
|
+
```mermaid
|
|
115
|
+
sequenceDiagram
|
|
116
|
+
participant P as Page in the loan tab
|
|
117
|
+
participant F as Firefox
|
|
118
|
+
participant W as foxlend webRequest layer
|
|
119
|
+
participant X as foxlend proxy layer
|
|
120
|
+
participant A as attacker.test
|
|
121
|
+
Note over P: Hidden text tells the agent to send the account number to attacker.test
|
|
122
|
+
P->>F: fetch, image, beacon, WebSocket, frame, redirect, service worker
|
|
123
|
+
F->>W: onBeforeRequest, cookieStoreId = loan container
|
|
124
|
+
W-->>F: cancel (host not on the allow list)
|
|
125
|
+
W-->>W: onBlocked: URL, type, initiator
|
|
126
|
+
P->>F: link rel=preconnect
|
|
127
|
+
F->>X: proxy.onRequest, type speculative
|
|
128
|
+
X-->>F: SOCKS proxy 127.0.0.1:9 (nothing answers)
|
|
129
|
+
Note over A: No request and no connection arrive
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Every failure mode has a test or an E2E check. See
|
|
133
|
+
[docs/failure-modes.md](docs/failure-modes.md).
|
|
134
|
+
|
|
135
|
+
## API
|
|
136
|
+
|
|
137
|
+
This package is a library only. It has no CLI and no MCP server, because
|
|
138
|
+
every part needs WebExtension APIs that exist only inside Firefox.
|
|
139
|
+
|
|
140
|
+
### `createFoxlend(options)`
|
|
141
|
+
|
|
142
|
+
Call it at the top level of the background script. It returns a frozen
|
|
143
|
+
object.
|
|
144
|
+
|
|
145
|
+
| Option | Default | What it does |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `browser` | required | The WebExtension `browser` object. |
|
|
148
|
+
| `host` | required | The foxgate `host`. foxlend calls `addGrant`, `revokeGrant`, and `grants`. |
|
|
149
|
+
| `publicSuffix` | `withDefaultRule(browser.publicSuffix)` | Finds the site of a host. Give foxgate the same object. |
|
|
150
|
+
| `now` | `Date.now` | The clock, in ms since 1970. |
|
|
151
|
+
| `maxTtlMs` | 24 hours | The longest loan. |
|
|
152
|
+
| `proxyLayer` | `true` when `browser.proxy` exists | Add the `proxy.onRequest` layer. |
|
|
153
|
+
| `stopPrediction` | `true` | Turn off network prediction while a loan is active. When foxlend cannot, `lend` throws `setting-failed`. `false` accepts the risk. |
|
|
154
|
+
| `stopWebRtc` | `true` | Turn off WebRTC while a loan is active. When foxlend cannot, `lend` throws `setting-failed`. `false` accepts the risk. |
|
|
155
|
+
| `storageKey` | `"foxlend"` | The `browser.storage.local` key for the loans. |
|
|
156
|
+
|
|
157
|
+
### The lender
|
|
158
|
+
|
|
159
|
+
| Member | What it does |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `lend(options)` | Lends one login and returns the `Loan`. Throws a `FoxlendError`. |
|
|
162
|
+
| `revoke(loanOrId)` | Takes the login back. Returns `false` when there is no such loan. |
|
|
163
|
+
| `listLoans()` | The active loans, and loans that wait for a revoke to finish. |
|
|
164
|
+
| `sweep()` | Revokes loans whose time is over and removes foxlend containers that no loan holds. foxlend runs it at start. |
|
|
165
|
+
| `onBlocked` | `addListener(fn)`. `fn` gets `{ loanId, url, host, type, initiator, layer, reason, at }`. |
|
|
166
|
+
| `onRevoked` | `addListener(fn)`. `fn` gets `{ loan, reason }`. `reason` is `user`, `ttl`, `startup`, or `permission`. |
|
|
167
|
+
|
|
168
|
+
### `lend` options
|
|
169
|
+
|
|
170
|
+
| Option | Default | What it does |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `domain` | required | The host to lend, for example `www.example.com`. |
|
|
173
|
+
| `scope` | required | The foxgate scope of the grant: `read`, `fill`, `submit`, or `pay`. |
|
|
174
|
+
| `ttlMs` | required | How long the loan lasts, in ms. |
|
|
175
|
+
| `allow` | `[]` | More hosts that pages in the loan may reach: exact hosts, or `*.` plus a host. |
|
|
176
|
+
| `url` | `https://<domain>/` | The task URL. It must be on a host that the loan allows. |
|
|
177
|
+
| `hidden` | `false` | Hide the task tab. Needs the `tabHide` permission. |
|
|
178
|
+
| `match` | `"site"` | `"site"` lends the registrable domain and its subdomains. `"host"` lends the exact host only, with only the cookies that host gets. |
|
|
179
|
+
| `tools` | every tool | The tool names that the foxgate grant allows. |
|
|
180
|
+
|
|
181
|
+
A `Loan` has these fields:
|
|
182
|
+
|
|
183
|
+
| Field | What it is |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `id`, `state` | The loan ID, and `creating`, `active`, or `revoking`. |
|
|
186
|
+
| `domain`, `site`, `match` | The lent host, its registrable domain, and the match mode. |
|
|
187
|
+
| `patterns` | The allow list: the lent site and the `allow` hosts. |
|
|
188
|
+
| `scope`, `grantId` | The foxgate scope and the ID of the grant. |
|
|
189
|
+
| `cookieStoreId`, `containerName` | The loan container. |
|
|
190
|
+
| `url`, `tabId`, `hidden` | The task tab. |
|
|
191
|
+
| `createdAt`, `expiresAt` | The start and the end, in ms since 1970. |
|
|
192
|
+
| `copied`, `skipped` | The number of cookies copied, and the cookies not copied, each with a reason. |
|
|
193
|
+
|
|
194
|
+
### Errors
|
|
195
|
+
|
|
196
|
+
`FoxlendError` has a `code`: `bad-domain`, `bad-allow`, `bad-ttl`, `bad-url`,
|
|
197
|
+
`bad-scope`, `setting-failed`, `no-host-access`, `lend-failed`, `revoke-failed`, or
|
|
198
|
+
`storage-error`. A failed
|
|
199
|
+
lend undoes its steps. A failed revoke keeps blocking the container, and the
|
|
200
|
+
next start tries again.
|
|
201
|
+
|
|
202
|
+
### Other exports
|
|
203
|
+
|
|
204
|
+
| Export | What it does |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `withDefaultRule(publicSuffix)` | Adds the default rule of the public suffix list. Firefox returns `null` for hosts on top-level domains that are not on the list, such as `.test` and `.localhost`. |
|
|
207
|
+
| `siteOf(host, publicSuffix)` | The registrable domain of a host. |
|
|
208
|
+
| `loanPatterns(input)` | The host patterns of a loan. |
|
|
209
|
+
| `planCopy(cookies, options)` | The `cookies.set` calls that copy one site's cookies. |
|
|
210
|
+
| `judge(request, loans, now, publicSuffix)` | The guard decision for one request. |
|
|
211
|
+
| `DEAD_PROXY`, `CONTAINER` | The proxy that blocked requests go to, and the container name prefix, color, and icon. |
|
|
212
|
+
|
|
213
|
+
### Demo extension
|
|
214
|
+
|
|
215
|
+
`extension/` is a demo for Firefox 153+. Its sidebar lends the current site
|
|
216
|
+
with a scope, a time limit, and an allow list. It lists the active loans with
|
|
217
|
+
Revoke, and it shows a live log of blocked requests.
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pnpm install
|
|
221
|
+
pnpm e2e # the full pitch flow in Firefox; writes artifacts/e2e-<date>.json
|
|
222
|
+
pnpm build:ext # builds dist-ext/; load it from about:debugging
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The E2E test serves a bank on `www.bank.localhost` with a login, and an
|
|
226
|
+
attacker on `attacker.test`. You log in to the bank in your own tab. Then the
|
|
227
|
+
test lends the bank from the sidebar. The agent tab opens logged in, on a page
|
|
228
|
+
with a hidden prompt injection. The page tries to send the account number to
|
|
229
|
+
`attacker.test` in nine ways: fetch, an image, a beacon, a WebSocket, a
|
|
230
|
+
frame, a redirect, a service worker, a preconnect, and a link prefetch. Each
|
|
231
|
+
one is blocked and logged. The attacker server gets no request and no
|
|
232
|
+
connection. The page also tries WebRTC to a local UDP listener. Before the
|
|
233
|
+
loan, your own tab reaches that listener, so the probe works. During the
|
|
234
|
+
loan, the page sends it no packet. Your own
|
|
235
|
+
tab can still reach `attacker.test`. After Revoke, the container is gone, and
|
|
236
|
+
your own tab is still logged in with the same cookies.
|
|
237
|
+
|
|
238
|
+
## Firefox APIs used
|
|
239
|
+
|
|
240
|
+
| API | MDN | Why |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| `contextualIdentities.create`, `get`, `query`, `remove` | [contextualIdentities](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities) | Make one container for each loan, and remove it. |
|
|
243
|
+
| `cookies.getAll`, `set`, `remove` with `storeId`, `partitionKey`, `firstPartyDomain` | [cookies](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies) | Read your cookies, copy one site's cookies into the loan container, and keep partitioned cookies in their partition. |
|
|
244
|
+
| `webRequest.onBeforeRequest` (blocking, `details.cookieStoreId`) | [webRequest](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest) | Cancel each request from a loan container to a host that is not on the list. Permissions `webRequest` and `webRequestBlocking`. |
|
|
245
|
+
| `proxy.onRequest` (`details.cookieStoreId`) | [proxy.onRequest](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/onRequest) | The second layer. It also stops speculative connections. |
|
|
246
|
+
| `privacy.network.networkPredictionEnabled` | [privacy.network](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network) | Turn off DNS prefetch and link prefetch while a loan is active. |
|
|
247
|
+
| `privacy.network.peerConnectionEnabled` | [privacy.network](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network) | Turn off WebRTC while a loan is active. Its UDP traffic passes neither guard layer. |
|
|
248
|
+
| `browsingData.remove` with `cookieStoreId` | [browsingData](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/remove) | Clear the cookies, local storage, and IndexedDB of the container. |
|
|
249
|
+
| `tabs.create` with `cookieStoreId`, `tabs.query`, `tabs.remove` | [tabs.create](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create) | Open the task tab in the container, and close every tab of the loan. |
|
|
250
|
+
| `tabs.hide` | [tabs.hide](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/hide) | Hide the task tab. Permission `tabHide`. |
|
|
251
|
+
| `publicSuffix.getDomain`, `getKnownSuffix` | [publicSuffix](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/publicSuffix) | Find the site of a host with no bundled list (Firefox 153+). |
|
|
252
|
+
| `permissions.contains`, `permissions.onRemoved` | [permissions](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions) | Refuse a loan without access to all sites, and revoke every loan when the user removes that access. |
|
|
253
|
+
| `alarms` | [alarms](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms) | Revoke a loan at its end, also when the event page is not loaded. |
|
|
254
|
+
| `runtime.onStartup` | [runtime.onStartup](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onStartup) | Revoke loans whose time ended while Firefox was closed. |
|
|
255
|
+
| `storage.local` | [storage.local](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/local) | Keep the loan records. |
|
|
256
|
+
| `storage.session`, `runtime.sendMessage`, `runtime.onMessage` | [runtime](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime) | The sidebar talks to the background page and reads the blocked log. Demo only. |
|
|
257
|
+
| `sidebarAction` (`sidebar_action`) | [sidebarAction](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/sidebarAction) | The demo sidebar. Demo only. |
|
|
258
|
+
| `tabs.captureTab` | [tabs.captureTab](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/captureTab) | The E2E test takes a screenshot of the loan tab. Test only. |
|
|
259
|
+
|
|
260
|
+
## Limits
|
|
261
|
+
|
|
262
|
+
- A lent cookie still lets the agent act as you on that site. The allow list
|
|
263
|
+
limits where data can go. It does not limit what the agent does on the
|
|
264
|
+
lent site. The foxgate scope works only when your agent loop asks the gate
|
|
265
|
+
before each action.
|
|
266
|
+
- Data can still leave through a host that is allowed, for example as a
|
|
267
|
+
comment on the lent site or an upload to an allowed host.
|
|
268
|
+
- Sessions that are bound to the device may fail in the container: client
|
|
269
|
+
certificates, sessions bound to an IP address or a TLS channel, and logins
|
|
270
|
+
that need a passkey at each use.
|
|
271
|
+
- foxlend does not change the cookies in your default container. The site
|
|
272
|
+
can still end your session on its side, for example when it rotates a
|
|
273
|
+
session token that the agent uses.
|
|
274
|
+
- Android has no containers, so foxlend works on desktop Firefox only.
|
|
275
|
+
- foxlend does not detect DNS rebinding. The allow list trusts the DNS of
|
|
276
|
+
the hosts on it.
|
|
277
|
+
- WebRTC and network prediction are off for all of Firefox while a loan is
|
|
278
|
+
active, also in your own tabs. No page can start a WebRTC call, for
|
|
279
|
+
example a video call, until the last loan ends. Without these settings off, WebRTC sends UDP
|
|
280
|
+
packets that neither guard layer sees (seen in Firefox 157).
|
|
281
|
+
- The network prediction setting does not stop everything. A
|
|
282
|
+
`<link rel="preconnect">` still happens with the setting off (seen in Firefox 157). The proxy layer stops
|
|
283
|
+
it. The E2E test cannot see DNS lookups, so it does not prove that DNS
|
|
284
|
+
prefetch stops.
|
|
285
|
+
- Firefox cannot clear service workers for one container. Service worker
|
|
286
|
+
data of the loan container can stay on disk after a revoke. Firefox did not
|
|
287
|
+
use the removed container ID again in our tests.
|
|
288
|
+
- The guard covers pages in the loan container. It does not cover your agent
|
|
289
|
+
code, for example the calls from your extension to a model API.
|
|
290
|
+
- The start sweep removes every container whose name starts with
|
|
291
|
+
`Agent · ` and that is purple with the fingerprint icon, when no loan holds
|
|
292
|
+
it. Do not use that combination for your own containers.
|
|
293
|
+
- When a site uses only `Secure` cookies, copies need an `https:` URL. The
|
|
294
|
+
E2E test uses `http:` sites on `.localhost`, so `Secure` cookies and
|
|
295
|
+
first-party isolation are covered by the Node tests only.
|
|
296
|
+
- Extensions that move tabs between containers (for example Multi-Account
|
|
297
|
+
Containers or Temporary Containers) can open a URL that foxlend blocked in
|
|
298
|
+
another container, outside the loan. Do not use them with foxlend.
|
|
299
|
+
- When foxlend cannot read its loan records (a storage error), the guard
|
|
300
|
+
blocks requests from all your containers other than the default one, not
|
|
301
|
+
only the loan containers. This is on purpose: it fails closed.
|
|
302
|
+
- Run one lender, in the background page. Two lenders on the same storage,
|
|
303
|
+
for example one in a sidebar and one in the background, can overwrite each
|
|
304
|
+
other's records.
|
|
305
|
+
- The proxy layer was not tested together with another extension that sets
|
|
306
|
+
a proxy.
|
|
307
|
+
- If the TTL ends while Firefox is closed, the revoke happens at the next
|
|
308
|
+
start. The copied cookies have already expired by then. Cookies that the
|
|
309
|
+
site set in the loan container during the loan (for example a new session
|
|
310
|
+
token) are not capped, so they stay valid until that revoke. The same
|
|
311
|
+
holds when a revoke fails or the extension is off.
|
|
312
|
+
|
|
313
|
+
## Part of the fox primitives
|
|
314
|
+
|
|
315
|
+
```mermaid
|
|
316
|
+
flowchart LR
|
|
317
|
+
foxkit[foxkit] -- template --> foxlend[foxlend]
|
|
318
|
+
foxgate[foxgate] --> foxlend
|
|
319
|
+
foxlend --> foxmate[foxmate]
|
|
320
|
+
click foxkit "https://github.com/pooriaarab/foxkit"
|
|
321
|
+
click foxgate "https://github.com/pooriaarab/foxgate"
|
|
322
|
+
click foxlend "https://github.com/pooriaarab/foxlend"
|
|
323
|
+
click foxmate "https://github.com/pooriaarab/foxmate"
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
foxlend depends on foxgate for the grant that matches each loan. foxmate,
|
|
327
|
+
the reference agent, lends logins with foxlend.
|
|
328
|
+
|
|
329
|
+
## License
|
|
330
|
+
|
|
331
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Cookie, CookieSetDetails } from "./cookies.js";
|
|
2
|
+
import type { PublicSuffixApi } from "./site.js";
|
|
3
|
+
export interface BrowserEvent<F> {
|
|
4
|
+
addListener(fn: F, ...rest: unknown[]): void;
|
|
5
|
+
}
|
|
6
|
+
/** The fields of webRequest and proxy request details that foxlend reads. */
|
|
7
|
+
export interface RequestDetails {
|
|
8
|
+
url: string;
|
|
9
|
+
type: string;
|
|
10
|
+
cookieStoreId?: string;
|
|
11
|
+
originUrl?: string;
|
|
12
|
+
documentUrl?: string;
|
|
13
|
+
tabId?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ContextualIdentity {
|
|
16
|
+
name: string;
|
|
17
|
+
color: string;
|
|
18
|
+
icon: string;
|
|
19
|
+
cookieStoreId: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ProxyInfo {
|
|
22
|
+
type: string;
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
proxyDNS?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** A browser-wide setting from `browser.privacy`. */
|
|
28
|
+
export interface BrowserSetting {
|
|
29
|
+
get(details: object): Promise<{
|
|
30
|
+
value: unknown;
|
|
31
|
+
levelOfControl: string;
|
|
32
|
+
}>;
|
|
33
|
+
set(details: {
|
|
34
|
+
value: boolean;
|
|
35
|
+
}): Promise<boolean>;
|
|
36
|
+
clear(details: object): Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
/** The browser-wide settings that foxlend turns off while a loan is active (E6, E17). */
|
|
39
|
+
export type BrowserSettingName = "networkPredictionEnabled" | "peerConnectionEnabled";
|
|
40
|
+
type Maybe<T> = T | undefined | Promise<T | undefined>;
|
|
41
|
+
export interface BrowserLike {
|
|
42
|
+
storage: {
|
|
43
|
+
local: {
|
|
44
|
+
get(key: string): Promise<Record<string, unknown>>;
|
|
45
|
+
set(items: Record<string, unknown>): Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
contextualIdentities: {
|
|
49
|
+
create(details: {
|
|
50
|
+
name: string;
|
|
51
|
+
color: string;
|
|
52
|
+
icon: string;
|
|
53
|
+
}): Promise<ContextualIdentity>;
|
|
54
|
+
remove(cookieStoreId: string): Promise<unknown>;
|
|
55
|
+
query(details: {
|
|
56
|
+
name?: string;
|
|
57
|
+
}): Promise<ContextualIdentity[]>;
|
|
58
|
+
get(cookieStoreId: string): Promise<ContextualIdentity>;
|
|
59
|
+
};
|
|
60
|
+
cookies: {
|
|
61
|
+
getAll(details: Record<string, unknown>): Promise<Cookie[]>;
|
|
62
|
+
set(details: CookieSetDetails): Promise<unknown>;
|
|
63
|
+
remove(details: Record<string, unknown>): Promise<unknown>;
|
|
64
|
+
};
|
|
65
|
+
tabs: {
|
|
66
|
+
create(details: {
|
|
67
|
+
url: string;
|
|
68
|
+
cookieStoreId: string;
|
|
69
|
+
active: boolean;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
id?: number;
|
|
72
|
+
}>;
|
|
73
|
+
query(details: {
|
|
74
|
+
cookieStoreId: string;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
id?: number;
|
|
77
|
+
}[]>;
|
|
78
|
+
remove(tabIds: number[]): Promise<void>;
|
|
79
|
+
hide(tabIds: number[]): Promise<unknown>;
|
|
80
|
+
};
|
|
81
|
+
browsingData: {
|
|
82
|
+
remove(options: {
|
|
83
|
+
cookieStoreId: string;
|
|
84
|
+
}, types: Record<string, boolean>): Promise<void>;
|
|
85
|
+
};
|
|
86
|
+
alarms: {
|
|
87
|
+
create(name: string, info: {
|
|
88
|
+
when: number;
|
|
89
|
+
}): unknown;
|
|
90
|
+
clear(name: string): Promise<boolean>;
|
|
91
|
+
onAlarm: BrowserEvent<(alarm: {
|
|
92
|
+
name: string;
|
|
93
|
+
}) => void>;
|
|
94
|
+
};
|
|
95
|
+
webRequest: {
|
|
96
|
+
onBeforeRequest: BrowserEvent<(details: RequestDetails) => Maybe<{
|
|
97
|
+
cancel: boolean;
|
|
98
|
+
}>>;
|
|
99
|
+
};
|
|
100
|
+
proxy?: {
|
|
101
|
+
onRequest: BrowserEvent<(details: RequestDetails) => Maybe<ProxyInfo>>;
|
|
102
|
+
};
|
|
103
|
+
privacy?: {
|
|
104
|
+
network: Record<BrowserSettingName, BrowserSetting | undefined>;
|
|
105
|
+
};
|
|
106
|
+
permissions: {
|
|
107
|
+
contains(permissions: {
|
|
108
|
+
origins: string[];
|
|
109
|
+
}): Promise<boolean>;
|
|
110
|
+
onRemoved: BrowserEvent<(permissions: {
|
|
111
|
+
origins?: string[];
|
|
112
|
+
}) => void>;
|
|
113
|
+
};
|
|
114
|
+
runtime: {
|
|
115
|
+
onStartup: BrowserEvent<() => void>;
|
|
116
|
+
};
|
|
117
|
+
publicSuffix?: PublicSuffixApi;
|
|
118
|
+
}
|
|
119
|
+
/** An event that foxlend fires. */
|
|
120
|
+
export interface Listenable<T> {
|
|
121
|
+
addListener(fn: (event: T) => void): void;
|
|
122
|
+
removeListener(fn: (event: T) => void): void;
|
|
123
|
+
}
|
|
124
|
+
/** A listener that throws does not stop the other listeners. */
|
|
125
|
+
export declare function emitter<T>(): {
|
|
126
|
+
event: Listenable<T>;
|
|
127
|
+
emit(value: T): void;
|
|
128
|
+
};
|
|
129
|
+
export {};
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A listener that throws does not stop the other listeners. */
|
|
2
|
+
export function emitter() {
|
|
3
|
+
const fns = new Set();
|
|
4
|
+
return {
|
|
5
|
+
event: { addListener: (fn) => void fns.add(fn), removeListener: (fn) => void fns.delete(fn) },
|
|
6
|
+
emit(value) {
|
|
7
|
+
for (const fn of fns) {
|
|
8
|
+
try {
|
|
9
|
+
fn(value);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// One broken listener must not hide the event from the others.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type PublicSuffix } from "foxgate";
|
|
2
|
+
/** A cookie as Firefox `cookies.getAll` returns it. */
|
|
3
|
+
export interface Cookie {
|
|
4
|
+
name: string;
|
|
5
|
+
value: string;
|
|
6
|
+
domain: string;
|
|
7
|
+
hostOnly: boolean;
|
|
8
|
+
path: string;
|
|
9
|
+
secure: boolean;
|
|
10
|
+
httpOnly: boolean;
|
|
11
|
+
sameSite: "no_restriction" | "lax" | "strict" | "unspecified";
|
|
12
|
+
session: boolean;
|
|
13
|
+
expirationDate?: number;
|
|
14
|
+
firstPartyDomain?: string;
|
|
15
|
+
partitionKey?: {
|
|
16
|
+
topLevelSite?: string;
|
|
17
|
+
hasCrossSiteAncestor?: boolean;
|
|
18
|
+
} | null;
|
|
19
|
+
storeId: string;
|
|
20
|
+
}
|
|
21
|
+
/** The details for one Firefox `cookies.set` call. */
|
|
22
|
+
export interface CookieSetDetails {
|
|
23
|
+
url: string;
|
|
24
|
+
name: string;
|
|
25
|
+
value: string;
|
|
26
|
+
domain?: string;
|
|
27
|
+
path: string;
|
|
28
|
+
secure: boolean;
|
|
29
|
+
httpOnly: boolean;
|
|
30
|
+
sameSite: Cookie["sameSite"];
|
|
31
|
+
expirationDate: number;
|
|
32
|
+
storeId: string;
|
|
33
|
+
firstPartyDomain?: string;
|
|
34
|
+
partitionKey?: {
|
|
35
|
+
topLevelSite?: string;
|
|
36
|
+
hasCrossSiteAncestor?: boolean;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export interface SkippedCookie {
|
|
40
|
+
name: string;
|
|
41
|
+
domain: string;
|
|
42
|
+
/** "other-partition": from another top-level site. "not-allowed": a third party not on the allow list. "set-failed": Firefox refused the copy. */
|
|
43
|
+
reason: "expired" | "other-partition" | "not-allowed" | "set-failed";
|
|
44
|
+
message?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface CopyOptions {
|
|
47
|
+
host: string;
|
|
48
|
+
match: "site" | "host";
|
|
49
|
+
/** From loanPatterns(). */
|
|
50
|
+
patterns: string[];
|
|
51
|
+
/** The loan container. */
|
|
52
|
+
storeId: string;
|
|
53
|
+
/** The loan end, in ms since 1970. */
|
|
54
|
+
endsAt: number;
|
|
55
|
+
now: number;
|
|
56
|
+
publicSuffix: PublicSuffix;
|
|
57
|
+
}
|
|
58
|
+
/** The cookies.set() calls that copy one site's cookies into the loan container. */
|
|
59
|
+
export declare function planCopy(cookies: Cookie[], options: CopyOptions): {
|
|
60
|
+
set: CookieSetDetails[];
|
|
61
|
+
skipped: SkippedCookie[];
|
|
62
|
+
};
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Which cookies a loan gets, and how (docs/failure-modes.md K1-K9, K11).
|
|
2
|
+
// planCopy only reads the cookies of the default container. It returns the
|
|
3
|
+
// cookies.set() calls for the loan container and never touches the source.
|
|
4
|
+
import { matchesPattern, parsePattern } from "foxgate";
|
|
5
|
+
import { hostOf, siteOf, within } from "./site.js";
|
|
6
|
+
const partitionSite = (topLevelSite, publicSuffix) => {
|
|
7
|
+
try {
|
|
8
|
+
return siteOf(new URL(topLevelSite).hostname, publicSuffix);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/** The cookies.set() calls that copy one site's cookies into the loan container. */
|
|
15
|
+
export function planCopy(cookies, options) {
|
|
16
|
+
const host = hostOf(options.host);
|
|
17
|
+
const site = siteOf(host, options.publicSuffix);
|
|
18
|
+
const allowed = options.patterns.map((p) => parsePattern(p, options.publicSuffix));
|
|
19
|
+
const set = [];
|
|
20
|
+
const skipped = [];
|
|
21
|
+
for (const cookie of cookies) {
|
|
22
|
+
const domain = cookie.domain.replace(/^\./, "").toLowerCase();
|
|
23
|
+
const skip = (reason) => skipped.push({ name: cookie.name, domain: cookie.domain, reason });
|
|
24
|
+
// First-party isolation: a cookie of the site kept for another first party (K14).
|
|
25
|
+
if (cookie.firstPartyDomain && cookie.firstPartyDomain !== site) {
|
|
26
|
+
if (within(domain, site))
|
|
27
|
+
skip("other-partition");
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// match "host": only what the browser sends to this host (K11, K13).
|
|
31
|
+
const hostGets = cookie.hostOnly ? domain === host : within(host, domain);
|
|
32
|
+
const topLevelSite = cookie.partitionKey?.topLevelSite;
|
|
33
|
+
if (topLevelSite) {
|
|
34
|
+
if (partitionSite(topLevelSite, options.publicSuffix) !== site) {
|
|
35
|
+
if (within(domain, site))
|
|
36
|
+
skip("other-partition");
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (within(domain, site)) {
|
|
40
|
+
if (options.match === "host" && !hostGets)
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
else if (!allowed.some((p) => matchesPattern(domain, p))) {
|
|
44
|
+
skip("not-allowed");
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (!within(domain, site))
|
|
50
|
+
continue;
|
|
51
|
+
if (options.match === "host" && !hostGets)
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const expires = cookie.session || cookie.expirationDate === undefined ? Infinity : cookie.expirationDate;
|
|
55
|
+
if (expires * 1000 <= options.now) {
|
|
56
|
+
skip("expired");
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
set.push({
|
|
60
|
+
url: `${cookie.secure ? "https" : "http"}://${domain}${cookie.path}`,
|
|
61
|
+
name: cookie.name,
|
|
62
|
+
value: cookie.value,
|
|
63
|
+
...(cookie.hostOnly ? {} : { domain: cookie.domain }),
|
|
64
|
+
path: cookie.path,
|
|
65
|
+
secure: cookie.secure,
|
|
66
|
+
httpOnly: cookie.httpOnly,
|
|
67
|
+
sameSite: cookie.sameSite,
|
|
68
|
+
expirationDate: Math.min(expires, options.endsAt / 1000),
|
|
69
|
+
storeId: options.storeId,
|
|
70
|
+
...(cookie.firstPartyDomain === undefined ? {} : { firstPartyDomain: cookie.firstPartyDomain }),
|
|
71
|
+
...(cookie.partitionKey ? { partitionKey: cookie.partitionKey } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { set, skipped };
|
|
75
|
+
}
|
package/dist/egress.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type PublicSuffix } from "foxgate";
|
|
2
|
+
/** What the guard needs to know about a loan. */
|
|
3
|
+
export interface LoanState {
|
|
4
|
+
id: string;
|
|
5
|
+
/** Not set until the container exists. */
|
|
6
|
+
cookieStoreId?: string;
|
|
7
|
+
/** From loanPatterns(). */
|
|
8
|
+
patterns: string[];
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
state: "creating" | "active" | "revoking";
|
|
11
|
+
}
|
|
12
|
+
/** The fields of a webRequest or proxy request details object that judge() reads. */
|
|
13
|
+
export interface RequestInfo {
|
|
14
|
+
url: string;
|
|
15
|
+
type: string;
|
|
16
|
+
cookieStoreId?: string;
|
|
17
|
+
}
|
|
18
|
+
export type BlockReason = "not-allowed" | "expired" | "revoking" | "bad-url" | "no-state" | "error";
|
|
19
|
+
export type Verdict = {
|
|
20
|
+
block: false;
|
|
21
|
+
} | {
|
|
22
|
+
block: true;
|
|
23
|
+
loanId: string;
|
|
24
|
+
reason: BlockReason;
|
|
25
|
+
host?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Decide one request. `loans` undefined means the loan list could not be
|
|
29
|
+
* read: then every container other than the default and the private one is
|
|
30
|
+
* blocked (E13).
|
|
31
|
+
*/
|
|
32
|
+
export declare function judge(request: RequestInfo, loans: LoanState[] | undefined, now: number, publicSuffix: PublicSuffix): Verdict;
|