Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/worker/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
test-dist/
# Compiled test output. The glob is deliberate: this rule was exactly
# `test-dist/`, and a scratch out-dir named `test-dist-q` was committed
# through the gap by a `git add -A` — 79 generated files that GitHub's
# default-setup CodeQL then scanned and filed findings against.
test-dist*/
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"gates": "bash scripts/gates.sh",
"test": "tsc -p tsconfig.test.json && node --test test/profile.test.mjs test/agent.test.mjs test/monitoring.test.mjs test/osint.test.mjs test/profile-helpers.test.mjs test/profile-db.test.mjs test/csv_person_import.test.mjs test/firm_geo_backfill.test.mjs test/profilers.test.mjs test/access_guard.test.mjs test/markMap.security.test.mjs test/profileWorkflows.test.mjs test/vc_sources.test.mjs src/crawler/adapters/__tests__/*.test.mjs src/crawler/adapters/deals/__tests__/*.test.mjs src/services/deals/__tests__/*.test.mjs src/ai/__tests__/*.test.mjs src/services/capTable/__tests__/*.test.mjs src/services/valuation/__tests__/*.test.mjs src/services/documents/__tests__/*.test.mjs test/verification.test.mjs test/verification.verifiers.test.mjs test/preflight.test.mjs test/proxyPool.test.mjs test/fetcher_proxy.test.mjs test/sweeper.test.mjs src/services/termSheets/__tests__/*.test.mjs src/services/fundReturns/__tests__/*.test.mjs src/services/edgeQuality/__tests__/*.test.mjs src/services/intros/__tests__/*.test.mjs src/services/diligence/__tests__/*.test.mjs src/services/founderCrm/__tests__/*.test.mjs src/services/mlOps/__tests__/*.test.mjs test/people.test.mjs test/overrides.test.mjs test/overrides_integration.test.mjs test/relationships_inference.test.mjs src/services/compute/__tests__/*.test.mjs src/services/systemHealth/__tests__/*.test.mjs test/investor_portfolio.test.mjs test/error_surfacing.test.mjs test/subrequest_budget.test.mjs test/errors_classify.test.mjs test/robots_skip.test.mjs test/do_merge_consistency.test.mjs test/search_sync_index.test.mjs test/comp_panel_snapshot.test.mjs test/account_score_batch.test.mjs test/accounts_sort_fallback.test.mjs test/dd_scores_by_ref.test.mjs test/job_state_transitions.test.mjs test/simple_request.test.mjs test/simple_request_client.test.mjs test/pagination_guard.test.mjs test/enrichment_budget.test.mjs test/org_entity_merge.test.mjs test/import_entity_mapping.test.mjs test/schema_drift.test.mjs test/garbage.test.mjs test/crawler_seeds.test.mjs test/identity_harvest.test.mjs test/personaMatching.test.mjs test/personaMatchingAcceptance.test.mjs test/personaMatchingDbContract.test.mjs test/profile_comments.test.mjs test/profiler_batch.test.mjs test/schema_drift_repo.test.mjs test/migrations_apply.test.mjs test/merge_repoints.test.mjs test/ci_wrangler_version.test.mjs test/soft_delete_roundtrip.test.mjs test/site_deeplinks.test.mjs test/persona_headcount.test.mjs test/crawler_run_meta.test.mjs test/diligence_founder_link.test.mjs test/edge_quality_sectors.test.mjs test/sector_resolution.test.mjs test/team_snapshot_eligibility.test.mjs",
"test": "tsc -p tsconfig.test.json && node --test test/profile.test.mjs test/agent.test.mjs test/monitoring.test.mjs test/osint.test.mjs test/profile-helpers.test.mjs test/profile-db.test.mjs test/csv_person_import.test.mjs test/firm_geo_backfill.test.mjs test/profilers.test.mjs test/access_guard.test.mjs test/cross_site_guard.test.mjs test/markMap.security.test.mjs test/profileWorkflows.test.mjs test/vc_sources.test.mjs src/crawler/adapters/__tests__/*.test.mjs src/crawler/adapters/deals/__tests__/*.test.mjs src/services/deals/__tests__/*.test.mjs src/ai/__tests__/*.test.mjs src/services/capTable/__tests__/*.test.mjs src/services/valuation/__tests__/*.test.mjs src/services/documents/__tests__/*.test.mjs test/verification.test.mjs test/verification.verifiers.test.mjs test/preflight.test.mjs test/proxyPool.test.mjs test/fetcher_proxy.test.mjs test/sweeper.test.mjs src/services/termSheets/__tests__/*.test.mjs src/services/fundReturns/__tests__/*.test.mjs src/services/edgeQuality/__tests__/*.test.mjs src/services/intros/__tests__/*.test.mjs src/services/diligence/__tests__/*.test.mjs src/services/founderCrm/__tests__/*.test.mjs src/services/mlOps/__tests__/*.test.mjs test/people.test.mjs test/overrides.test.mjs test/overrides_integration.test.mjs test/relationships_inference.test.mjs src/services/compute/__tests__/*.test.mjs src/services/systemHealth/__tests__/*.test.mjs test/investor_portfolio.test.mjs test/error_surfacing.test.mjs test/subrequest_budget.test.mjs test/errors_classify.test.mjs test/robots_skip.test.mjs test/do_merge_consistency.test.mjs test/search_sync_index.test.mjs test/comp_panel_snapshot.test.mjs test/account_score_batch.test.mjs test/accounts_sort_fallback.test.mjs test/dd_scores_by_ref.test.mjs test/job_state_transitions.test.mjs test/simple_request.test.mjs test/simple_request_client.test.mjs test/pagination_guard.test.mjs test/enrichment_budget.test.mjs test/org_entity_merge.test.mjs test/import_entity_mapping.test.mjs test/schema_drift.test.mjs test/garbage.test.mjs test/crawler_seeds.test.mjs test/identity_harvest.test.mjs test/personaMatching.test.mjs test/personaMatchingAcceptance.test.mjs test/personaMatchingDbContract.test.mjs test/profile_comments.test.mjs test/profiler_batch.test.mjs test/schema_drift_repo.test.mjs test/migrations_apply.test.mjs test/merge_repoints.test.mjs test/ci_wrangler_version.test.mjs test/soft_delete_roundtrip.test.mjs test/site_deeplinks.test.mjs test/persona_headcount.test.mjs test/crawler_run_meta.test.mjs test/diligence_founder_link.test.mjs test/edge_quality_sectors.test.mjs test/sector_resolution.test.mjs test/team_snapshot_eligibility.test.mjs",
"cf:provision": "node scripts/provision-cf.mjs"
},
"dependencies": {
Expand Down
27 changes: 17 additions & 10 deletions apps/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export { OSINTResolveEntityWorkflow, OSINTBatchWorkflow, OSINTReverifyWorkflow }
export { RefreshSavedResearchWorkflow } from "./agent/workflow";
import { piiAuditOnLeadGet } from "./middleware/pii_audit";
import { accessGuard, adminOnly } from "./middleware/access";
import { crossSiteGuard, ALLOWED_ORIGINS } from "./middleware/origin";
import { requestId } from "./middleware/request_id";
import { unwrapSimpleRequest } from "./middleware/simple_request";
import { boundedPagination } from "./middleware/pagination";
Expand All @@ -112,16 +113,11 @@ api.use("*", requestId);
api.use(
"*",
cors({
origin: (origin) => {
const allowed = new Set([
"https://aidatasignal.com",
"https://www.aidatasignal.com",
// README/Replit deployment target for the dashboard (DNS pending).
"https://app.aidatasignal.com",
]);
if (origin && allowed.has(origin)) return origin;
return null;
},
// ALLOWED_ORIGINS is shared with crossSiteGuard rather than duplicated
// here. Two copies is the one realistic way that guard locks the
// dashboard out of its own API: CORS would permit an origin the guard
// then refuses, and every write would 403 until someone reverted it.
origin: (origin) => (origin && ALLOWED_ORIGINS.has(origin) ? origin : null),
credentials: true,
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Cf-Access-Jwt-Assertion", "Idempotency-Key"],
Expand All @@ -147,6 +143,17 @@ api.route("/api/webhooks/campaigns", campaignsWebhook);
// reach /api/compute/* without an Access cookie.
api.route("/api/compute", computeRunnerRoute);
api.use("/api/*", accessGuard);
// accessGuard accepts the CF_Authorization cookie on its own, so an ambient
// cookie authenticates a write — the shape CSRF exploits. CORS does not stop
// it: it governs whether the attacker can READ the response, and no preflight
// fires for a form POST, which 84 of this API's mutating handlers accept
// because they parse no body at all and act on the path alone.
//
// Mounted HERE, immediately after accessGuard, on purpose. It covers exactly
// the Access-authenticated surface and leaves alone the two routers mounted
// above it, which authenticate differently and are called by non-browsers:
// /api/webhooks/campaigns and /api/compute (per-node HMAC envelope).
api.use("/api/*", crossSiteGuard);
// Reject negative / non-numeric limit+offset once, for every list route
// (SQLite treats a negative LIMIT as unbounded; NaN binds as NULL → 500).
api.use("/api/*", boundedPagination);
Expand Down
97 changes: 97 additions & 0 deletions apps/worker/src/middleware/origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Cross-site write protection.
//
// `accessGuard` accepts the `CF_Authorization` cookie on its own (see
// middleware/access.ts). An ambient cookie is therefore sufficient to
// authenticate a write, which is the exact shape CSRF exploits: a page on
// another origin causes the victim's browser to issue the request, and the
// browser attaches the cookie without the attacker ever seeing it.
//
// CORS does not prevent this. It governs whether the attacker can READ the
// response, not whether the write executes. And the preflight that would
// block a JSON request never fires for a "simple request" — a form POST with
// `application/x-www-form-urlencoded`. 84 of this API's mutating handlers
// parse no request body at all; they act on the path alone, so a simple
// form POST reaches them intact. Among them `POST /api/ops/garbage/:id/purge`,
// which permanently deletes an entity and cascades across facts, rel_edges,
// channels, entity_roles, entity_history and entity_legacy_map.
//
// Whether a browser actually attaches `CF_Authorization` cross-site depends
// on that cookie's SameSite attribute, which is Cloudflare Access
// configuration and not visible from this repository. This guard is worth
// having either way precisely because it does not depend on a setting we
// cannot see from here.

import type { MiddlewareHandler } from "hono";
import type { Env } from "../types";

/**
* Origins allowed to make authenticated cross-origin requests.
*
* Exported and shared with the `cors()` configuration in index.ts on purpose.
* Two copies of this list is the one realistic way for this guard to lock the
* dashboard out of its own API: CORS would permit an origin that the guard
* then rejects, and every write would 403 until someone reverted it.
*/
export const ALLOWED_ORIGINS: ReadonlySet<string> = new Set([
"https://aidatasignal.com",
"https://www.aidatasignal.com",
// README/Replit deployment target for the dashboard (DNS pending).
"https://app.aidatasignal.com",
]);

/** Methods that cannot change state, so cannot be a CSRF target. */
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

/**
* `Sec-Fetch-Site` values that mean the request came from somewhere else.
*
* `same-site` is deliberately NOT here: the dashboard is served from
* aidatasignal.com and calls api.aidatasignal.com, which is cross-ORIGIN but
* same-SITE. Rejecting it would block every write the dashboard makes.
*/
const FOREIGN_FETCH_SITES = new Set(["cross-site", "cross-origin"]);

/**
* Reject a mutating request only on positive evidence that it came from
* another site.
*
* The ordering matters:
*
* 1. Safe methods are never blocked. There are no state-changing GET
* handlers in this worker (verified), so reads need no protection and
* blocking them would only break the dashboard.
* 2. An `Origin` we recognise passes; one we do not is refused. Browsers
* always send `Origin` on cross-origin requests and on same-origin
* non-GET requests, so this is the main path.
* 3. Failing that, `Sec-Fetch-Site` — sent by every current browser — is
* consulted so a same-origin request that omitted `Origin` is still
* judged on real evidence.
* 4. Neither header present means the caller is not a browser, and is
* allowed.
*
* Step 4 is load-bearing rather than a loophole. Non-browser callers
* (scripts/provision-cf.mjs, the deploy workflow, external compute runners)
* send neither header, and a client that has to supply its own credential is
* not the threat this guards against — CSRF is about a credential the browser
* attaches on the attacker's behalf. Refusing requests for an ABSENT header
* would break those callers and buy nothing: an attacker who can set headers
* can set `Origin` too.
*/
export const crossSiteGuard: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
if (SAFE_METHODS.has(c.req.method.toUpperCase())) return next();

const origin = c.req.header("Origin");
if (origin) {
if (!ALLOWED_ORIGINS.has(origin)) {
return c.json({ error: "cross_site_write_blocked", origin }, 403);
}
return next();
}

const fetchSite = c.req.header("Sec-Fetch-Site")?.toLowerCase();
if (fetchSite && FOREIGN_FETCH_SITES.has(fetchSite)) {
return c.json({ error: "cross_site_write_blocked", sec_fetch_site: fetchSite }, 403);
}

return next();
};
46 changes: 0 additions & 46 deletions apps/worker/test-dist-q/ai/budget.js

This file was deleted.

33 changes: 0 additions & 33 deletions apps/worker/test-dist-q/ai/cache.js

This file was deleted.

Loading
Loading