Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
csrf.ts5.2 KB · 166 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/**
 * CSRF Protection Middleware
 *
 * Generates and validates CSRF tokens for form submissions.
 * Uses double-submit cookie pattern for stateless CSRF protection.
 */

import { createMiddleware } from "hono/factory";
import { getCookie, setCookie } from "hono/cookie";

const CSRF_COOKIE = "csrf_token";
const CSRF_HEADER = "x-csrf-token";
const CSRF_FIELD = "_csrf";

function generateToken(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(32));
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Sets a CSRF cookie on every request if not already present.
 * Also makes the token available via c.get("csrfToken").
 */
export const csrfToken = createMiddleware(async (c, next) => {
  let token = getCookie(c, CSRF_COOKIE);
  if (!token) {
    token = generateToken();
    setCookie(c, CSRF_COOKIE, token, {
      httpOnly: false, // JS needs to read it
      sameSite: "Lax",
      path: "/",
      secure: process.env.NODE_ENV === "production",
    });
  }
  c.set("csrfToken", token);
  return next();
});

/**
 * Validates CSRF on mutating requests (POST, PUT, DELETE, PATCH).
 *
 * Defence-in-depth, in order:
 *  1. Same-origin check via Origin/Referer header (OWASP-recommended primary
 *     defence against CSRF — a cross-site forged request from a victim's
 *     browser always carries the attacker's Origin, never the app's host).
 *  2. Double-submit cookie token (the `_csrf` form field or `X-CSRF-Token`
 *     header must equal the `csrf_token` cookie). Optional — used as a
 *     belt-and-braces fallback when present.
 *
 * The Origin check alone is sufficient for modern browsers (all major
 * browsers send Origin on cross-origin POSTs). The token check is retained
 * because some legacy clients strip Origin/Referer, and because it gives
 * forms an explicit "I came from a real page" signal.
 *
 * A request is accepted iff EITHER the Origin/Referer matches the request
 * host OR the token check passes.
 */
export const csrfProtect = createMiddleware(async (c, next) => {
  const method = c.req.method.toUpperCase();
  if (["GET", "HEAD", "OPTIONS"].includes(method)) {
    return next();
  }

  // Skip CSRF for API routes with Bearer token auth (they have their own auth)
  const authHeader = c.req.header("Authorization");
  if (authHeader?.startsWith("Bearer ")) {
    return next();
  }

  // Skip CSRF for API routes (they use token auth, not cookies)
  const path = c.req.path;
  if (path.startsWith("/api/")) {
    return next();
  }

  // Skip CSRF for git protocol routes
  if (path.endsWith(".git/git-upload-pack") || path.endsWith(".git/git-receive-pack")) {
    return next();
  }

  // Skip CSRF for requests with no session cookie — they are unauthenticated
  // and will be redirected to /login (or 404) by downstream auth middleware.
  // CSRF only matters for authenticated, cookie-bearing sessions because the
  // attack vector is a malicious site tricking a logged-in user's browser.
  const sessionCookie = getCookie(c, "session");
  if (!sessionCookie) {
    return next();
  }

  // ---- 1) Same-origin check (Origin / Referer header) -----------------
  // A genuine same-origin request from our own pages will carry an Origin
  // (always for cross-origin POSTs, usually for same-origin too) or a
  // Referer that matches the request host. Cross-site forged requests
  // either carry the attacker's origin or are stripped — in both cases
  // they fail this check.
  const host = c.req.header("host");
  const origin = c.req.header("origin");
  const referer = c.req.header("referer");
  let originOk = false;
  if (host) {
    if (origin) {
      try {
        originOk = new URL(origin).host === host;
      } catch {
        originOk = false;
      }
    } else if (referer) {
      try {
        originOk = new URL(referer).host === host;
      } catch {
        originOk = false;
      }
    }
  }
  if (originOk) {
    return next();
  }

  // ---- 2) Double-submit cookie token (fallback) ------------------------
  const cookieToken = getCookie(c, CSRF_COOKIE);
  if (!cookieToken) {
    return c.text("CSRF check failed: no Origin/Referer header and no token cookie", 403);
  }

  // Check header first, then form body
  let submittedToken = c.req.header(CSRF_HEADER);
  if (!submittedToken) {
    try {
      const contentType = c.req.header("content-type") || "";
      if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
        const body = await c.req.parseBody();
        submittedToken = String(body[CSRF_FIELD] || "");
      }
    } catch {
      // Can't parse body — skip
    }
  }

  // For JSON API calls from the web UI
  if (!submittedToken) {
    try {
      const contentType = c.req.header("content-type") || "";
      if (contentType.includes("application/json")) {
        const body = await c.req.json();
        submittedToken = body?._csrf;
      }
    } catch {
      // Can't parse JSON — skip
    }
  }

  if (!submittedToken || submittedToken !== cookieToken) {
    return c.text("CSRF token invalid", 403);
  }

  return next();
});

/**
 * Helper to generate a hidden CSRF input field for forms.
 */
export function csrfField(token: string): string {
  return `<input type="hidden" name="${CSRF_FIELD}" value="${token}" />`;
}