← 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 →