hearth-dash 1.1.3 → 1.1.4

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 (3) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/worker.js +27 -4
package/README.md CHANGED
@@ -126,6 +126,10 @@ Version 1.1.2 accepts Chrome's legitimate `Origin: null` on a form submission on
126
126
 
127
127
  Version 1.1.3 replaces the OAuth consent page's double-submit CSRF cookie with a short-lived, one-time D1 token bound to the complete authorization request and consumed atomically. This avoids browser cookie-context failures during Railway/Claude handoff without weakening same-origin checks or replay protection.
128
128
 
129
+ ### 1.1.4 OAuth callback CSP fix
130
+
131
+ Version 1.1.4 gives each authorization page a narrow `form-action` policy containing only Hearth itself and that request's already validated HTTP(S) OAuth callback origin. This allows the browser to follow the successful POST redirect without permitting arbitrary form destinations.
132
+
129
133
  ## Security notes
130
134
 
131
135
  - There are no functional default credentials, bearer tokens or secret-bearing connector URLs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hearth-dash",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
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
@@ -68,9 +68,11 @@ async function verifySession(token, secret) {
68
68
  }
69
69
  }
70
70
 
71
+ const CSP_BASE = "default-src 'self'; img-src 'self' data: https://openweathermap.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'";
72
+
71
73
  function securityHeaders(extra = {}) {
72
74
  return {
73
- 'Content-Security-Policy': "default-src 'self'; img-src 'self' data: https://openweathermap.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
75
+ 'Content-Security-Policy': `${CSP_BASE}; form-action 'self'`,
74
76
  'Referrer-Policy': 'no-referrer',
75
77
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
76
78
  'X-Content-Type-Options': 'nosniff',
@@ -80,6 +82,21 @@ function securityHeaders(extra = {}) {
80
82
  };
81
83
  }
82
84
 
85
+ function oauthCallbackCspSource(redirectUri) {
86
+ const redirect = new URL(redirectUri);
87
+ if (!['http:', 'https:'].includes(redirect.protocol) || redirect.origin === 'null') {
88
+ throw new Error('Unsupported OAuth redirect URI scheme');
89
+ }
90
+ return redirect.origin;
91
+ }
92
+
93
+ function oauthConsentHeaders(callbackSource, extra = {}) {
94
+ return securityHeaders({
95
+ ...extra,
96
+ 'Content-Security-Policy': `${CSP_BASE}; form-action 'self' ${callbackSource}`,
97
+ });
98
+ }
99
+
83
100
  function sessionCookie(value, maxAge = 604800) {
84
101
  return `__Host-hearth_session=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${maxAge}`;
85
102
  }
@@ -747,7 +764,7 @@ async function consumeOAuthCsrf(env, oauthRequest, token) {
747
764
  function validAuthorizationParameterMultiplicity(url) {
748
765
  const singleton = [
749
766
  'response_type', 'client_id', 'redirect_uri', 'scope', 'state',
750
- 'code_challenge', 'code_challenge_method',
767
+ 'code_challenge', 'code_challenge_method', 'resource',
751
768
  ];
752
769
  return singleton.every(name => url.searchParams.getAll(name).length <= 1);
753
770
  }
@@ -805,6 +822,12 @@ export const oauthDefaultHandler = {
805
822
  }
806
823
  const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
807
824
  if (!client) return new Response('Unknown OAuth client', { status: 400, headers: securityHeaders() });
825
+ let callbackCspSource;
826
+ try {
827
+ callbackCspSource = oauthCallbackCspSource(oauthRequest.redirectUri);
828
+ } catch {
829
+ return new Response('Unsupported OAuth callback scheme', { status: 400, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
830
+ }
808
831
 
809
832
  const sessionToken = getCookie(request, '__Host-hearth_session');
810
833
  const signedIn = await verifySession(sessionToken, config.SESSION_SECRET);
@@ -819,7 +842,7 @@ export const oauthDefaultHandler = {
819
842
  } catch {
820
843
  return csrfStorageUnavailable();
821
844
  }
822
- const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
845
+ const headers = new Headers(oauthConsentHeaders(callbackCspSource, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
823
846
  return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, signedIn), { headers });
824
847
  }
825
848
 
@@ -861,7 +884,7 @@ export const oauthDefaultHandler = {
861
884
  } catch {
862
885
  return csrfStorageUnavailable();
863
886
  }
864
- const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
887
+ const headers = new Headers(oauthConsentHeaders(callbackCspSource, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
865
888
  return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, false, 'Wrong dashboard password'), { status: 401, headers });
866
889
  }
867
890