driggsby 0.7.0 → 0.7.1
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/dist/dev/app-policy.js +78 -0
- package/dist/dev/dev-servers.js +33 -18
- package/dist/dev/host-page.js +7 -2
- package/package.json +3 -3
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The security headers on every app-origin response, the service worker
|
|
2
|
+
// the app origin hands out, and the policy the host page requires of its
|
|
3
|
+
// frame: a copy of what Driggsby's serving host and console do for a
|
|
4
|
+
// deployed app, so the preview refuses what a deployed app is refused and a
|
|
5
|
+
// page that works here works once deployed. The Driggsby service is
|
|
6
|
+
// canonical: change it there first, then mirror it here.
|
|
7
|
+
//
|
|
8
|
+
// The content security policy keeps the page's loads on its own origin
|
|
9
|
+
// (another site's script, font, image, or fetch is refused), refuses frames,
|
|
10
|
+
// objects, forms, and eval, and sandboxes the page as the serving host does.
|
|
11
|
+
// frame-ancestors names the host page, the only page that may frame the
|
|
12
|
+
// app. A file that can run script (a page, an SVG, JavaScript) also carries
|
|
13
|
+
// Connection-Allowlist, which has Chrome refuse any connection the page
|
|
14
|
+
// starts to anywhere else, WebRTC included. Any other file gets neither the
|
|
15
|
+
// list nor script: its sandbox grants nothing, so it opens in an origin of
|
|
16
|
+
// its own, as it does deployed. Never widen the list to other files; the
|
|
17
|
+
// serving host sends it only where script can run.
|
|
18
|
+
const SCRIPT_MEDIA_TYPES = new Set([
|
|
19
|
+
"text/html",
|
|
20
|
+
"image/svg+xml",
|
|
21
|
+
"text/javascript",
|
|
22
|
+
"application/javascript",
|
|
23
|
+
]);
|
|
24
|
+
const POLICY_DIRECTIVES = [
|
|
25
|
+
"default-src 'self'",
|
|
26
|
+
"script-src 'self' 'unsafe-inline'",
|
|
27
|
+
"style-src 'self' 'unsafe-inline'",
|
|
28
|
+
"img-src 'self' data: blob:",
|
|
29
|
+
"font-src 'self' data:",
|
|
30
|
+
"connect-src 'self'",
|
|
31
|
+
"frame-src 'none'",
|
|
32
|
+
"object-src 'none'",
|
|
33
|
+
"form-action 'none'",
|
|
34
|
+
"base-uri 'none'",
|
|
35
|
+
];
|
|
36
|
+
// What the host page requires of every document its frame shows (the
|
|
37
|
+
// iframe's csp attribute), as the console does: it binds even a document
|
|
38
|
+
// that doesn't come from this server.
|
|
39
|
+
export const FRAME_POLICY = POLICY_DIRECTIVES.join("; ");
|
|
40
|
+
// The only service worker either preview origin hands out, verbatim from
|
|
41
|
+
// the serving host. An app's own worker could answer the frame's
|
|
42
|
+
// navigations with documents it builds, carrying none of these headers, and
|
|
43
|
+
// on these fixed ports it would outlive the preview and answer the next
|
|
44
|
+
// app's too; a worker some other local server once left on the host port
|
|
45
|
+
// would answer the host page, which runs tools. This one listens for
|
|
46
|
+
// nothing.
|
|
47
|
+
export const INERT_SERVICE_WORKER = `// This origin's only service worker. It listens for nothing, so it answers
|
|
48
|
+
// no request: every page and file comes from the network with the host's
|
|
49
|
+
// own headers. It behaves like any default worker: it waits until no page
|
|
50
|
+
// is using the worker before it, then takes over.
|
|
51
|
+
`;
|
|
52
|
+
// hostOrigin is empty until the host server has bound (a startup race
|
|
53
|
+
// measured in milliseconds); until then nothing may frame the app.
|
|
54
|
+
export function appResponseHeaders(hostOrigin, contentType) {
|
|
55
|
+
const mediaType = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
56
|
+
const runsScript = SCRIPT_MEDIA_TYPES.has(mediaType);
|
|
57
|
+
const directives = [
|
|
58
|
+
runsScript ? "sandbox allow-scripts allow-same-origin" : "sandbox",
|
|
59
|
+
...POLICY_DIRECTIVES,
|
|
60
|
+
`frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin}`,
|
|
61
|
+
];
|
|
62
|
+
const headers = {
|
|
63
|
+
"Content-Security-Policy": directives.join("; "),
|
|
64
|
+
// The served content types are final; the browser never guesses.
|
|
65
|
+
"X-Content-Type-Options": "nosniff",
|
|
66
|
+
"Referrer-Policy": "no-referrer",
|
|
67
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
68
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
69
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
|
|
70
|
+
};
|
|
71
|
+
if (hostOrigin !== "") {
|
|
72
|
+
headers["Allow-CSP-From"] = hostOrigin;
|
|
73
|
+
}
|
|
74
|
+
if (runsScript) {
|
|
75
|
+
headers["Connection-Allowlist"] = "(response-origin)";
|
|
76
|
+
}
|
|
77
|
+
return headers;
|
|
78
|
+
}
|
package/dist/dev/dev-servers.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// only. The app token never appears here — the broker callback holds it in
|
|
6
6
|
// the CLI process.
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
|
+
import { appResponseHeaders, INERT_SERVICE_WORKER } from "./app-policy.js";
|
|
8
9
|
import { DEV_IDENTITY_PATH } from "./dev-state.js";
|
|
9
10
|
import { hostPageHtml, HOST_PAGE_JS } from "./host-page.js";
|
|
10
11
|
import { serveStaticFile } from "./static-files.js";
|
|
@@ -15,13 +16,16 @@ import { APP_TOOL_ALLOWLIST } from "./tool-allowlist.js";
|
|
|
15
16
|
export const GENERIC_TOOL_TROUBLE = "We couldn't run that just now. Try again in a moment.";
|
|
16
17
|
export const TOOL_NOT_ALLOWED_MESSAGE = "That tool isn't available to Driggsby apps. Apps can use read-only tools like get_overview and list_accounts.";
|
|
17
18
|
const MAX_TOOL_CALL_BODY_BYTES = 65_536;
|
|
19
|
+
const PLAIN_TEXT = "text/plain; charset=utf-8";
|
|
20
|
+
const JAVASCRIPT = "text/javascript; charset=utf-8";
|
|
18
21
|
export async function startDevServers(options) {
|
|
19
22
|
// Both origins bind and advertise 127.0.0.1: advertising "localhost"
|
|
20
23
|
// while binding only the IPv4 loopback breaks on machines whose browser
|
|
21
24
|
// resolves localhost to ::1 first.
|
|
22
25
|
let appPortForGuard = 0;
|
|
23
|
-
// Filled in once the host server binds; app responses
|
|
24
|
-
// frame-ancestors
|
|
26
|
+
// Filled in once the host server binds; app responses name it in their
|
|
27
|
+
// frame-ancestors and Allow-CSP-From, so only the real host page can
|
|
28
|
+
// frame the app.
|
|
25
29
|
let hostOriginForFraming = "";
|
|
26
30
|
const appServer = createServer((request, response) => {
|
|
27
31
|
void handleAppRequest(options, appPortForGuard, hostOriginForFraming, request, response);
|
|
@@ -70,39 +74,41 @@ export async function startDevServers(options) {
|
|
|
70
74
|
// The app origin: the SDK path, then the project's own files.
|
|
71
75
|
// ---------------------------------------------------------------------------
|
|
72
76
|
async function handleAppRequest(options, appPort, hostOrigin, request, response) {
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
const appHeaders = {
|
|
79
|
-
"Content-Security-Policy": `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin};`,
|
|
80
|
-
"X-Content-Type-Options": "nosniff",
|
|
81
|
-
};
|
|
77
|
+
// Every response carries the deployed app's headers (app-policy.ts). Their
|
|
78
|
+
// frame-ancestors lets only the real host page frame the app: without it,
|
|
79
|
+
// any other loopback-served page could embed the app origin, pin itself
|
|
80
|
+
// as the app's host, and feed it fabricated numbers.
|
|
81
|
+
const plainTextHeaders = appResponseHeaders(hostOrigin, PLAIN_TEXT);
|
|
82
82
|
// Same rebinding guard as the host origin: a page on another site that
|
|
83
83
|
// tricked DNS into pointing here carries its own Host, and fails.
|
|
84
84
|
if (!hostHeaderIsLocal(request.headers.host, appPort)) {
|
|
85
|
-
sendText(response, 403,
|
|
85
|
+
sendText(response, 403, PLAIN_TEXT, "Forbidden.", plainTextHeaders);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Every service worker script request, whatever path it names, gets the
|
|
89
|
+
// inert worker (app-policy.ts), as deployed. Browsers mark each one.
|
|
90
|
+
if (isServiceWorkerScriptRequest(request)) {
|
|
91
|
+
sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER, appResponseHeaders(hostOrigin, JAVASCRIPT));
|
|
86
92
|
return;
|
|
87
93
|
}
|
|
88
94
|
const path = requestPath(request);
|
|
89
95
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
90
|
-
sendText(response, 405,
|
|
96
|
+
sendText(response, 405, PLAIN_TEXT, "Method not allowed.", plainTextHeaders);
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
if (path === "/-/driggsby-sdk.js") {
|
|
94
|
-
sendText(response, 200,
|
|
100
|
+
sendText(response, 200, JAVASCRIPT, options.sdkBundle, appResponseHeaders(hostOrigin, JAVASCRIPT));
|
|
95
101
|
return;
|
|
96
102
|
}
|
|
97
103
|
const file = await serveStaticFile(options.serveDirectory, path);
|
|
98
104
|
if (file === null) {
|
|
99
|
-
sendText(response, 404,
|
|
105
|
+
sendText(response, 404, PLAIN_TEXT, "Not found.", plainTextHeaders);
|
|
100
106
|
return;
|
|
101
107
|
}
|
|
102
108
|
response.writeHead(file.status, {
|
|
103
109
|
"Content-Type": file.contentType,
|
|
104
110
|
"Cache-Control": "no-store",
|
|
105
|
-
...
|
|
111
|
+
...appResponseHeaders(hostOrigin, file.contentType),
|
|
106
112
|
});
|
|
107
113
|
response.end(file.body);
|
|
108
114
|
}
|
|
@@ -117,6 +123,12 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
|
|
|
117
123
|
sendJson(response, 403, { ok: false, error: { message: "Forbidden." } });
|
|
118
124
|
return;
|
|
119
125
|
}
|
|
126
|
+
// The host page registers no worker, so a worker script request here is
|
|
127
|
+
// a foreign one; the inert worker replaces it (app-policy.ts).
|
|
128
|
+
if (isServiceWorkerScriptRequest(request)) {
|
|
129
|
+
sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
120
132
|
const path = requestPath(request);
|
|
121
133
|
if (request.method === "GET" && path === DEV_IDENTITY_PATH) {
|
|
122
134
|
// Lets `dev --stop` (and `dev`) confirm that a record in ~/.driggsby
|
|
@@ -129,7 +141,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
|
|
|
129
141
|
return;
|
|
130
142
|
}
|
|
131
143
|
if (request.method === "GET" && path === "/host.js") {
|
|
132
|
-
sendText(response, 200,
|
|
144
|
+
sendText(response, 200, JAVASCRIPT, HOST_PAGE_JS);
|
|
133
145
|
return;
|
|
134
146
|
}
|
|
135
147
|
if (request.method === "GET" && path === "/events") {
|
|
@@ -150,7 +162,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
|
|
|
150
162
|
await handleToolCall(options, hostPort, request, response);
|
|
151
163
|
return;
|
|
152
164
|
}
|
|
153
|
-
sendText(response, 404,
|
|
165
|
+
sendText(response, 404, PLAIN_TEXT, "Not found.");
|
|
154
166
|
return;
|
|
155
167
|
}
|
|
156
168
|
async function handleToolCall(options, hostPort, request, response) {
|
|
@@ -209,6 +221,9 @@ function requestPath(request) {
|
|
|
209
221
|
const queryStart = url.indexOf("?");
|
|
210
222
|
return queryStart === -1 ? url : url.slice(0, queryStart);
|
|
211
223
|
}
|
|
224
|
+
function isServiceWorkerScriptRequest(request) {
|
|
225
|
+
return request.headers["service-worker"] === "script" || request.headers["sec-fetch-dest"] === "serviceworker";
|
|
226
|
+
}
|
|
212
227
|
function hostHeaderIsLocal(host, port) {
|
|
213
228
|
return host === `localhost:${String(port)}` || host === `127.0.0.1:${String(port)}`;
|
|
214
229
|
}
|
package/dist/dev/host-page.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// inside Driggsby. The page carries no token and no data; every tool call
|
|
6
6
|
// goes to the CLI's own /tool-calls endpoint, which holds the token and
|
|
7
7
|
// enforces the allowlist and bounds.
|
|
8
|
+
import { FRAME_POLICY } from "./app-policy.js";
|
|
8
9
|
// The page's script is served as its own file so the page can carry a
|
|
9
10
|
// Content-Security-Policy with no inline scripts.
|
|
10
11
|
//
|
|
@@ -14,7 +15,11 @@
|
|
|
14
15
|
// `appOrigin` is built by startDevServers from a literal plus a bound port
|
|
15
16
|
// number, and `background` has passed readProjectConfig's strict hex-color
|
|
16
17
|
// rule (or is null). Anything looser than those sources must not be
|
|
17
|
-
// passed here.
|
|
18
|
+
// passed here. FRAME_POLICY is a constant with no double quote in it.
|
|
19
|
+
//
|
|
20
|
+
// The frame gets the console's sandbox and, as its csp attribute, the
|
|
21
|
+
// deployed policy (app-policy.ts), which Chrome then requires of every
|
|
22
|
+
// document the frame shows, even one this server didn't send.
|
|
18
23
|
export function hostPageHtml(slug, appOrigin, background) {
|
|
19
24
|
return `<!doctype html>
|
|
20
25
|
<html lang="en" data-app-origin="${appOrigin}">
|
|
@@ -84,7 +89,7 @@ export function hostPageHtml(slug, appOrigin, background) {
|
|
|
84
89
|
<span class="slug">${slug}</span>
|
|
85
90
|
<span class="mode">driggsby dev — your live data</span>
|
|
86
91
|
</header>
|
|
87
|
-
<iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin
|
|
92
|
+
<iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin" csp="${FRAME_POLICY}"></iframe>
|
|
88
93
|
<script type="module" src="/host.js"></script>
|
|
89
94
|
</body>
|
|
90
95
|
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Set up and build on Driggsby, the MCP server for personal finance.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@driggsby/deploy": "0.7.
|
|
32
|
-
"@driggsby/sdk": "0.7.
|
|
31
|
+
"@driggsby/deploy": "0.7.1",
|
|
32
|
+
"@driggsby/sdk": "0.7.1"
|
|
33
33
|
}
|
|
34
34
|
}
|