hearth-dash 1.1.1 → 1.1.2

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/README.md CHANGED
@@ -95,7 +95,7 @@ claude mcp add --transport http hearth-dash https://your-worker.example/mcp
95
95
 
96
96
  Then open `/mcp` inside Claude Code and complete authentication. Claude Code uses a loopback callback rather than Claude.ai's hosted callback; DCR handles its varying local port.
97
97
 
98
- ## Upgrading from 1.0.1
98
+ ## Upgrading from 1.0.1
99
99
 
100
100
  Version 1.0.1 labelled a custom `{ "tool": ..., "params": ... }` HTTP handler as MCP. It did not implement MCP JSON-RPC or tool discovery and could not work as a Claude.ai custom connector. Version 1.1.0 replaces it with Streamable HTTP MCP and OAuth 2.1. The old payload and secret-bearing URL formats are intentionally rejected.
101
101
 
@@ -110,11 +110,15 @@ Existing deployments must:
110
110
  7. Remove and re-add the custom connector using `https://your-worker.example/mcp`.
111
111
  8. Delete the obsolete secret with `npx wrangler secret delete MCP_SECRET` after the new deployment works.
112
112
 
113
- The first-visit password setup page has also been removed. A public, unclaimed setup page allowed the first visitor—not necessarily the owner—to take control of a new deployment. Configure `DASHBOARD_PASSWORD` as a Worker secret instead.
114
-
113
+ The first-visit password setup page has also been removed. A public, unclaimed setup page allowed the first visitor—not necessarily the owner—to take control of a new deployment. Configure `DASHBOARD_PASSWORD` as a Worker secret instead.
114
+
115
115
  ### 1.1.1 dashboard-login fix
116
116
 
117
117
  Version 1.1.1 keeps ordinary dashboard, login and API requests outside the OAuth provider and makes same-origin form validation resilient when a trusted Cloudflare wrapper reconstructs the internal request URL. Cross-site browser submissions remain rejected. Upgrade with `npx hearth-dash@latest deploy` if a 1.1.0 deployment returns plain `Forbidden` after submitting `/login`.
118
+
119
+ ### 1.1.2 Chrome null-Origin fix
120
+
121
+ Version 1.1.2 accepts Chrome's legitimate `Origin: null` on a form submission only when the browser's unforgeable Fetch Metadata independently classifies the request as `same-origin`. Mismatched, malformed, same-site and cross-site requests remain rejected.
118
122
 
119
123
  ## Security notes
120
124
 
package/oauth-entry.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
2
- import { applicationHandler, isOAuthRoute, oauthApiHandler, oauthDefaultHandler, withinRateLimit } from './worker.js';
2
+ import { applicationHandler, isOAuthRoute, oauthApiHandler, oauthDefaultHandler, withinRateLimit } from './worker.js';
3
3
 
4
4
  const OAUTH_SCOPES = ['hearth:read', 'hearth:write'];
5
5
 
@@ -34,14 +34,14 @@ function createOAuthProvider(request, env) {
34
34
  });
35
35
  }
36
36
 
37
- export default {
38
- fetch(request, env, ctx) {
39
- // Keep ordinary dashboard traffic out of the OAuth provider. Besides being
40
- // unnecessary, wrapping /login and /api requests can obscure the browser's
41
- // public origin on some Cloudflare routes and make valid CSRF checks fail.
42
- if (!isOAuthRoute(new URL(request.url).pathname)) {
43
- return applicationHandler.fetch(request, env, ctx);
44
- }
45
- return createOAuthProvider(request, env).fetch(request, env, ctx);
46
- },
47
- };
37
+ export default {
38
+ fetch(request, env, ctx) {
39
+ // Keep ordinary dashboard traffic out of the OAuth provider. Besides being
40
+ // unnecessary, wrapping /login and /api requests can obscure the browser's
41
+ // public origin on some Cloudflare routes and make valid CSRF checks fail.
42
+ if (!isOAuthRoute(new URL(request.url).pathname)) {
43
+ return applicationHandler.fetch(request, env, ctx);
44
+ }
45
+ return createOAuthProvider(request, env).fetch(request, env, ctx);
46
+ },
47
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hearth-dash",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Personal dashboard — moods, moments, food diary, weather, and more. Deploy to Cloudflare in one command.",
5
5
  "type": "module",
6
6
  "bin": {
package/worker.js CHANGED
@@ -85,31 +85,51 @@ function sessionCookie(value, maxAge = 604800) {
85
85
  }
86
86
 
87
87
  export function sameOrigin(request) {
88
- const origin = request.headers.get('Origin');
89
- if (!origin) return true;
90
- let parsedOrigin;
91
- try { parsedOrigin = new URL(origin); } catch { return false; }
92
- if (parsedOrigin.origin === new URL(request.url).origin) return true;
88
+ const targetOrigin = new URL(request.url).origin;
89
+ const fetchSite = request.headers.get('Sec-Fetch-Site');
90
+ const suppliedOrigin = classifyOrigin(request.headers.get('Origin'));
93
91
 
94
- // Host and Fetch Metadata describe the browser-facing request even if a
95
- // trusted Worker wrapper has reconstructed request.url with an internal
96
- // origin. Browsers do not let cross-origin pages forge either value.
97
- const host = request.headers.get('Host');
98
- if (host && parsedOrigin.host === host) return true;
99
- return request.headers.get('Sec-Fetch-Site') === 'same-origin';
92
+ // Fetch Metadata is browser-controlled and cannot be forged by hostile page
93
+ // JavaScript. Chrome can legitimately serialize Origin as "null" here, so a
94
+ // same-origin classification is the decisive signal for null/missing Origin.
95
+ if (fetchSite === 'same-origin') {
96
+ if (suppliedOrigin.kind === 'missing' || suppliedOrigin.kind === 'null') return true;
97
+ return suppliedOrigin.kind === 'origin' && suppliedOrigin.value === targetOrigin;
98
+ }
99
+
100
+ // Reject explicit cross-origin classifications even if another header is
101
+ // malformed or contradictory. "same-site" can still be another subdomain.
102
+ if (fetchSite === 'cross-site' || fetchSite === 'same-site' || fetchSite === 'none') return false;
103
+
104
+ // Older/non-browser clients without Fetch Metadata must provide one exact,
105
+ // valid HTTP(S) Origin. Unknown future Fetch Metadata values use this same
106
+ // conservative fallback.
107
+ return suppliedOrigin.kind === 'origin' && suppliedOrigin.value === targetOrigin;
100
108
  }
101
109
 
102
- export function isOAuthRoute(pathname) {
103
- return pathname === '/mcp'
104
- || pathname.startsWith('/mcp/')
105
- || pathname === '/authorize'
106
- || pathname === '/oauth/token'
107
- || pathname === '/oauth/register'
108
- || pathname === '/.well-known/oauth-authorization-server'
109
- || pathname === '/.well-known/oauth-protected-resource'
110
- || pathname.startsWith('/.well-known/oauth-protected-resource/');
110
+ function classifyOrigin(value) {
111
+ if (value === null) return { kind: 'missing' };
112
+ if (value === 'null') return { kind: 'null' };
113
+ try {
114
+ const parsed = new URL(value);
115
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value) return { kind: 'invalid' };
116
+ return { kind: 'origin', value: parsed.origin };
117
+ } catch {
118
+ return { kind: 'invalid' };
119
+ }
111
120
  }
112
121
 
122
+ export function isOAuthRoute(pathname) {
123
+ return pathname === '/mcp'
124
+ || pathname.startsWith('/mcp/')
125
+ || pathname === '/authorize'
126
+ || pathname === '/oauth/token'
127
+ || pathname === '/oauth/register'
128
+ || pathname === '/.well-known/oauth-authorization-server'
129
+ || pathname === '/.well-known/oauth-protected-resource'
130
+ || pathname.startsWith('/.well-known/oauth-protected-resource/');
131
+ }
132
+
113
133
  export async function withinRateLimit(env, request, scope, limit, windowSeconds) {
114
134
  const address = request.headers.get('CF-Connecting-IP') || 'unknown';
115
135
  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(address)));