index.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. /**
  2. * codegraph telemetry dashboard — stats.getcodegraph.com
  3. *
  4. * The private counterpart to `telemetry-worker/`: that one writes events into
  5. * D1, this one reads them back for the two people who look at the numbers.
  6. *
  7. * Everything is deny-by-default. `assets.run_worker_first` is `true` in
  8. * wrangler.jsonc, so the static-asset server never sees a request this file has
  9. * not already authorised — the only unauthenticated surface is the login page,
  10. * which the worker renders inline, and robots.txt.
  11. *
  12. * D1 is read-only here. Writes belong to the ingest worker's cron.
  13. */
  14. import { handleApi } from './api';
  15. import {
  16. checkPassword,
  17. clearedSessionCookie,
  18. hasValidSession,
  19. isSameOriginPost,
  20. issueSession,
  21. sessionCookie,
  22. } from './auth';
  23. import { renderLoginPage } from './login-page';
  24. const MAX_LOGIN_BODY_BYTES = 4 * 1024;
  25. const ROBOTS_TXT = 'User-agent: *\nDisallow: /\n';
  26. /**
  27. * Security headers for every response. `styleNonce` is only passed for the
  28. * inline-styled login page; asset-served pages link a stylesheet instead.
  29. */
  30. function securityHeaders(styleNonce?: string): Record<string, string> {
  31. const styleSrc = styleNonce ? `'self' 'nonce-${styleNonce}'` : "'self'";
  32. return {
  33. 'content-security-policy': [
  34. "default-src 'none'",
  35. "script-src 'self'",
  36. `style-src ${styleSrc}`,
  37. "img-src 'self' data:",
  38. "font-src 'self'",
  39. "connect-src 'self'",
  40. "form-action 'self'",
  41. "base-uri 'none'",
  42. "frame-ancestors 'none'",
  43. ].join('; '),
  44. 'x-content-type-options': 'nosniff',
  45. 'x-frame-options': 'DENY',
  46. 'referrer-policy': 'no-referrer',
  47. 'cross-origin-opener-policy': 'same-origin',
  48. };
  49. }
  50. function withSecurityHeaders(response: Response, styleNonce?: string): Response {
  51. const out = new Response(response.body, response);
  52. for (const [name, value] of Object.entries(securityHeaders(styleNonce))) {
  53. out.headers.set(name, value);
  54. }
  55. return out;
  56. }
  57. /**
  58. * Builds the response headers. Extras go through `new Headers(...)` rather than
  59. * an object spread: spreading a `Headers` instance silently yields `{}`, and
  60. * losing a `set-cookie` that way would be a very quiet bug.
  61. */
  62. function headersWith(defaults: Record<string, string>, extra?: HeadersInit): Headers {
  63. const headers = new Headers(defaults);
  64. if (extra) {
  65. for (const [name, value] of new Headers(extra)) headers.set(name, value);
  66. }
  67. return headers;
  68. }
  69. function html(body: string, init: ResponseInit & { nonce?: string } = {}): Response {
  70. const { nonce, headers, ...rest } = init;
  71. return withSecurityHeaders(
  72. new Response(body, {
  73. ...rest,
  74. headers: headersWith(
  75. {
  76. 'content-type': 'text/html; charset=utf-8',
  77. // Never let a page render from cache after sign-out.
  78. 'cache-control': 'no-store',
  79. },
  80. headers,
  81. ),
  82. }),
  83. nonce,
  84. );
  85. }
  86. function json(body: unknown, init: ResponseInit = {}): Response {
  87. const { headers, ...rest } = init;
  88. return withSecurityHeaders(
  89. new Response(JSON.stringify(body), {
  90. ...rest,
  91. headers: headersWith(
  92. {
  93. 'content-type': 'application/json; charset=utf-8',
  94. 'cache-control': 'no-store',
  95. },
  96. headers,
  97. ),
  98. }),
  99. );
  100. }
  101. function redirect(location: string, init: ResponseInit = {}): Response {
  102. const { headers, status, ...rest } = init;
  103. return withSecurityHeaders(
  104. new Response(null, {
  105. ...rest,
  106. status: status ?? 302,
  107. headers: headersWith({ location, 'cache-control': 'no-store' }, headers),
  108. }),
  109. );
  110. }
  111. /**
  112. * Only same-origin absolute paths survive, so `?next=` can never become an open
  113. * redirect. `//evil.example` and `/\evil.example` are protocol-relative URLs in
  114. * a browser, not paths — hence the second character check.
  115. */
  116. function safeNextPath(candidate: string | null): string {
  117. if (!candidate || !candidate.startsWith('/')) return '/';
  118. if (candidate.startsWith('//') || candidate.startsWith('/\\')) return '/';
  119. return candidate;
  120. }
  121. function loginRedirect(url: URL): Response {
  122. const next = `${url.pathname}${url.search}`;
  123. const target = next === '/' ? '/login' : `/login?next=${encodeURIComponent(next)}`;
  124. return redirect(target);
  125. }
  126. function nonce(): string {
  127. const bytes = crypto.getRandomValues(new Uint8Array(16));
  128. return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
  129. }
  130. /** Best-effort brute-force cap on the shared password, keyed by client IP. */
  131. async function loginRateLimitOk(env: Env, request: Request): Promise<boolean> {
  132. // Note for auditors: unlike the ingest worker — which never reads the client
  133. // IP — this admin login does, purely as a rate-limit key. It is not stored,
  134. // logged or forwarded anywhere.
  135. const key = request.headers.get('cf-connecting-ip') ?? 'unknown';
  136. try {
  137. const { success } = await env.LOGIN_RATE_LIMITER.limit({ key });
  138. return success;
  139. } catch (err) {
  140. // Fail open: a rate-limiter outage must not lock the maintainer out, and
  141. // the password is still required either way.
  142. console.error(JSON.stringify({ msg: 'login rate limiter unavailable', err: String(err) }));
  143. return true;
  144. }
  145. }
  146. async function handleLoginPage(env: Env, request: Request, url: URL): Promise<Response> {
  147. const next = safeNextPath(url.searchParams.get('next'));
  148. if (await hasValidSession(env, request)) return redirect(next);
  149. const styleNonce = nonce();
  150. return html(renderLoginPage({ next, nonce: styleNonce }), { nonce: styleNonce });
  151. }
  152. async function handleLoginSubmit(env: Env, request: Request): Promise<Response> {
  153. if (!isSameOriginPost(request)) {
  154. return new Response('bad request\n', { status: 400 });
  155. }
  156. const contentLength = Number(request.headers.get('content-length'));
  157. if (Number.isFinite(contentLength) && contentLength > MAX_LOGIN_BODY_BYTES) {
  158. return new Response('payload too large\n', { status: 413 });
  159. }
  160. let form: FormData;
  161. try {
  162. form = await request.formData();
  163. } catch {
  164. return new Response('bad request\n', { status: 400 });
  165. }
  166. const next = safeNextPath(String(form.get('next') ?? '/'));
  167. const password = form.get('password');
  168. const styleNonce = nonce();
  169. const fail = (error: string, status: number): Response =>
  170. html(renderLoginPage({ next, error, nonce: styleNonce }), { status, nonce: styleNonce });
  171. if (!(await loginRateLimitOk(env, request))) {
  172. return fail('Too many attempts. Wait a minute and try again.', 429);
  173. }
  174. if (typeof password !== 'string' || password.length === 0) {
  175. return fail('Enter the password to continue.', 400);
  176. }
  177. if (!(await checkPassword(env, password))) {
  178. return fail('That password is not right.', 401);
  179. }
  180. return redirect(next, { headers: { 'set-cookie': sessionCookie(await issueSession(env)) } });
  181. }
  182. /**
  183. * The chart endpoints live in src/api.ts and return data, not responses, so this
  184. * file stays the single place that decides headers on an authenticated reply.
  185. * Everything under `/api/` is behind the same session check as the pages.
  186. */
  187. async function apiResponse(env: Env, url: URL): Promise<Response> {
  188. const result = await handleApi(env, url);
  189. return json(result.body, {
  190. status: result.status,
  191. // Chart data is daily-granular, so a few minutes in the browser's private
  192. // cache saves D1 a round of identical queries on every panel re-render.
  193. // Anything without an explicit lifetime keeps the no-store default.
  194. headers: result.cacheControl ? { 'cache-control': result.cacheControl } : undefined,
  195. });
  196. }
  197. /** Gated static assets: the dashboard shell, its JS, its CSS, the chart library. */
  198. async function serveAsset(env: Env, request: Request): Promise<Response> {
  199. const asset = await env.ASSETS.fetch(request);
  200. const out = withSecurityHeaders(asset);
  201. // Behind a session, so it must never land in a shared cache.
  202. out.headers.set('cache-control', 'private, no-cache');
  203. out.headers.set('vary', 'cookie');
  204. return out;
  205. }
  206. export default {
  207. async fetch(request, env): Promise<Response> {
  208. try {
  209. const url = new URL(request.url);
  210. const method = request.method;
  211. const isRead = method === 'GET' || method === 'HEAD';
  212. // --- unauthenticated surface: exactly these three routes ---------------
  213. if (isRead && url.pathname === '/robots.txt') {
  214. return new Response(ROBOTS_TXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
  215. }
  216. if (url.pathname === '/login') {
  217. if (isRead) return await handleLoginPage(env, request, url);
  218. if (method === 'POST') return await handleLoginSubmit(env, request);
  219. return new Response('method not allowed\n', { status: 405, headers: { allow: 'GET, POST' } });
  220. }
  221. if (url.pathname === '/logout') {
  222. if (method !== 'POST') {
  223. return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
  224. }
  225. if (!isSameOriginPost(request)) return new Response('bad request\n', { status: 400 });
  226. return redirect('/login', { headers: { 'set-cookie': clearedSessionCookie() } });
  227. }
  228. // --- everything else needs a session -----------------------------------
  229. const isApi = url.pathname === '/api' || url.pathname.startsWith('/api/');
  230. if (!(await hasValidSession(env, request))) {
  231. return isApi ? json({ error: 'unauthorized' }, { status: 401 }) : loginRedirect(url);
  232. }
  233. if (isApi) {
  234. if (!isRead) {
  235. return json({ error: 'method not allowed' }, { status: 405, headers: { allow: 'GET' } });
  236. }
  237. return await apiResponse(env, url);
  238. }
  239. if (!isRead) {
  240. return new Response('method not allowed\n', { status: 405, headers: { allow: 'GET' } });
  241. }
  242. return await serveAsset(env, request);
  243. } catch (err) {
  244. console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
  245. return new Response('internal error\n', { status: 500 });
  246. }
  247. },
  248. } satisfies ExportedHandler<Env>;