[object Object]

← back to Ca Donations

ca-donations: harden public gate — strict positive allowlist, Object.hasOwn agg validator, explicit column lists (Cody FIX-FIRST)

80ce7b129000288cacfadc0e6f78df301de19b64 · 2026-08-24 12:00:22 -0700 · Steve Abrams

Files touched

Diff

commit 80ce7b129000288cacfadc0e6f78df301de19b64
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 12:00:22 2026 -0700

    ca-donations: harden public gate — strict positive allowlist, Object.hasOwn agg validator, explicit column lists (Cody FIX-FIRST)
---
 server.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++----------------
 1 file changed, 48 insertions(+), 16 deletions(-)

diff --git a/server.js b/server.js
index 396e495..51cf97a 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,7 @@
 // ca-donations — Basic-Auth searchable data product over CA donation public records.
 // Two families: charitable (orgs + grants) and political (donor-level contributions).
 import express from 'express';
+import path from 'node:path';
 import { q, qWithTimeout } from './lib/db.js';
 
 const app = express();
@@ -14,19 +15,43 @@ const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
 const PUBLIC_TIER = process.env.PUBLIC_TIER === '1';
 const PUBLIC_POLITICAL_AGG = process.env.PUBLIC_POLITICAL_AGG === '1';
 
-// When the public tier is on, ONLY these read-only charitable/org paths open to
-// the public. /api/political (raw donor rows) NEVER opens. Static public/ opens
-// via the isPublicPath check below. Everything else stays Basic-Auth gated.
-const publicApiPath = (path) =>
-  path === '/api/stats' ||
-  path === '/api/orgs' ||
-  path === '/api/grants' ||
-  path.startsWith('/api/org/') ||
-  (PUBLIC_POLITICAL_AGG && path === '/api/political/agg');
+// STRICT POSITIVE ALLOWLIST. There is deliberately NO "anything not under /api/"
+// fallback: the gate opens a path only when it EXACTLY matches a known-public API
+// route, or is a genuine safe static asset resolving inside public/. Anything that
+// falls through returns false and stays Basic-Auth gated — so a future file like
+// public/political-export.json can never be silently served to the public.
+
+// Exact-match public charitable/org API endpoints (raw political donor rows are
+// never on this list). /api/political/agg is exact-match AND still gated behind
+// PUBLIC_POLITICAL_AGG. Org detail is the one prefix (/api/org/:ein).
+const PUBLIC_API_EXACT = new Set(['/api/stats', '/api/orgs', '/api/grants']);
+const publicApiPath = (p) =>
+  PUBLIC_API_EXACT.has(p) ||
+  p.startsWith('/api/org/') ||
+  (PUBLIC_POLITICAL_AGG && p === '/api/political/agg');
+
+// Genuine static assets only: no /api prefix, a known-safe extension, and the
+// resolved path must stay inside public/ (blocks traversal / encoded escapes).
+const STATIC_EXT = new Set(['.html', '.css', '.js', '.mjs', '.txt', '.ico', '.svg',
+  '.png', '.jpg', '.jpeg', '.gif', '.webp', '.woff', '.woff2', '.map']);
+const PUBLIC_ROOT = path.resolve('public');
+const isSafeStaticPath = (p) => {
+  if (p.startsWith('/api')) return false;
+  let rel;
+  try { rel = p === '/' ? 'index.html' : decodeURIComponent(p).replace(/^\/+/, ''); }
+  catch { return false; }
+  if (!STATIC_EXT.has(path.extname(rel).toLowerCase())) return false;
+  const resolved = path.resolve(PUBLIC_ROOT, rel);
+  return resolved === PUBLIC_ROOT || resolved.startsWith(PUBLIC_ROOT + path.sep);
+};
 
-const isPublicPath = (path) =>
-  path === '/healthz' ||
-  (PUBLIC_TIER && (publicApiPath(path) || !path.startsWith('/api/')));
+const isPublicPath = (p) => {
+  if (p === '/healthz') return true;
+  if (!PUBLIC_TIER) return false;
+  if (publicApiPath(p)) return true;
+  if (isSafeStaticPath(p)) return true;
+  return false;
+};
 
 // --- Basic Auth gate (401 = healthy). Open paths per the tier allowlist. ---
 app.use((req, res, next) => {
@@ -102,8 +127,13 @@ app.get('/api/orgs', async (req, res) => {
 app.get('/api/org/:ein', async (req, res) => {
   try {
     res.set('X-Robots-Tag', 'noindex');
-    const [org] = await q('SELECT * FROM charitable_orgs WHERE ein=$1', [req.params.ein]);
-    const grantsOut = await q('SELECT * FROM charitable_grants WHERE grantor_ein=$1 ORDER BY tax_year DESC, amount DESC LIMIT 200', [req.params.ein]);
+    const [org] = await q(
+      `SELECT ein,name,city,state,ntee_code,subsection,ca_ag_status
+       FROM charitable_orgs WHERE ein=$1`, [req.params.ein]);
+    const grantsOut = await q(
+      `SELECT grantor_ein,grantor_name,grantee_name,grantee_city,amount,tax_year,grant_type
+       FROM charitable_grants WHERE grantor_ein=$1
+       ORDER BY tax_year DESC, amount DESC LIMIT 200`, [req.params.ein]);
     res.json({ org: org || null, grants_out: grantsOut });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
@@ -155,8 +185,10 @@ const AGG_GROUP = {
 };
 app.get('/api/political/agg', async (req, res) => {
   try {
-    const col = AGG_GROUP[req.query.by];
-    if (!col) return res.status(400).json({ error: 'by must be one of: ' + Object.keys(AGG_GROUP).join('|') });
+    const by = req.query.by;
+    if (typeof by !== 'string' || !Object.hasOwn(AGG_GROUP, by))
+      return res.status(400).json({ error: 'by must be one of: ' + Object.keys(AGG_GROUP).join('|') });
+    const col = AGG_GROUP[by];
     const limit = Math.min(+req.query.limit || 100, 500);
     // Bounded — a full-table GROUP BY over ~15M rows must cancel cleanly, not hang.
     const rows = await qWithTimeout(

← b285cb4 ca-donations: public tier behind PUBLIC_TIER flag (default-o  ·  back to Ca Donations  ·  ca-donations: gated go-live runbook (Kamatera migration + ng 15aa128 →