Skip to content
28 changes: 19 additions & 9 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,9 @@ Configure these rules in the Cloudflare dashboard under **Security → WAF**.
**Where:** Security → WAF → Custom Rules
**Action:** Block

**Previous expression (pre-`/_app/` rollout, synthesized from `scripts/waf-smoke-test.sh`):**
**Previous expression (pre-`/_app/` rollout):**
```
not (
http.request.uri.path in {"/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy" "/index.html"} or
starts_with(http.request.uri.path, "/assets/") or
starts_with(http.request.uri.path, "/api/")
)
not (http.request.uri.path eq "/" or http.request.uri.path eq "/index.html" or http.request.uri.path eq "/login" or http.request.uri.path eq "/oauth/callback" or http.request.uri.path eq "/onboarding" or http.request.uri.path eq "/dashboard" or http.request.uri.path eq "/settings" or http.request.uri.path eq "/privacy" or starts_with(http.request.uri.path, "/assets/") or starts_with(http.request.uri.path, "/api/"))
```

**Updated expression (allows `/_app/*` hashed chunks and static/metadata routes on `GET`/`HEAD`):**
Expand All @@ -311,6 +307,20 @@ not (
- Blocks vulnerability scanners and probes for unmapped paths (`/wp-admin`, `/.env`, `/.git/config`, arbitrary paths) at the edge before the Worker or static asset router runs.
- `scripts/waf-smoke-test.sh` (`pnpm test:waf`) validates this rule before every commit (`prek`) and deploy (`deploy.yml`).

#### Rule 2: Scanner User-Agents

**Rule name:** Scanner User-Agents (block empty and malicious User-Agents)
**Where:** Security → WAF → Custom Rules
**Expression:**
```
(http.user_agent eq "" or lower(http.user_agent) contains "sqlmap" or lower(http.user_agent) contains "nikto" or lower(http.user_agent) contains "nmap" or lower(http.user_agent) contains "masscan" or lower(http.user_agent) contains "zgrab")
```
**Action:** Block

**Notes:**
- Blocks automated vulnerability scanners (`sqlmap`, `nikto`, `nmap`, `masscan`, `zgrab`) and requests with an empty `User-Agent` header at the edge before the Worker runs.
- Validated by `scripts/waf-smoke-test.sh` (`pnpm test:waf`).

#### Rule 3: Origin Gate

**Rule name:** Block API requests without valid Origin
Expand All @@ -327,12 +337,12 @@ not (http.request.uri.path eq "/api/error-reporting")
**Exemptions:** `/api/csp-report` and `/api/error-reporting` are excluded because the Worker enforces its own strict origin check on both endpoints. See `hack/docs/security-runbook.md` for exemption rationale.

**Notes:**
- This uses **1 of the 5 free WAF custom rules** available on all plans.
- Rules 1–3 use **3 of the 5 free WAF custom rules** available on all plans.
- Blocks scanners, `curl` without `Origin`, and cross-site browser attacks before the Worker runs (never billed as a Worker request).

### Rate Limiting Rules

> **Conditional:** WAF rate limiting rules may require a **Pro plan** or above. If unavailable on your current Cloudflare plan (Free plan), skip this step. The Workers Rate Limiting Binding provides per-session rate limiting instead, and the WAF custom rule (above) still enforces the Origin check layer.
> **Note:** Cloudflare Free plan supports 1 unmanaged WAF rate limiting rule with a 10-second period (`60 requests per 10 seconds`) and a 10-second mitigation timeout (`Block for 10 seconds` on Free plan; `Block for 60 seconds` or longer on Pro and above). The Workers Rate Limiting Binding provides additional per-session rate limiting inside the Worker.

**Rule name:** Rate limit API proxy endpoints
**Where:** Security → WAF → Rate Limiting Rules
Expand All @@ -342,7 +352,7 @@ not (http.request.uri.path eq "/api/error-reporting")
(http.request.method ne "OPTIONS")
```
**Rate:** 60 requests per 10 seconds per IP
**Action:** Block for 60 seconds
**Action:** Block for 10 seconds (Free plan) / Block for 60 seconds (Pro+)

See `hack/docs/security-runbook.md` for implementation details.

Expand Down
8 changes: 4 additions & 4 deletions scripts/waf-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ TESTS=(
"200|GET /settings|${BASE}/settings"
"200|GET /privacy|${BASE}/privacy"
"307|GET /index.html (html_handling redirect)|${BASE}/index.html"
"403,200,404|GET /_app/nonexistent.js|${BASE}/_app/nonexistent.js"
"404|GET /_app/nonexistent.js|${BASE}/_app/nonexistent.js"
"200|GET /assets/nonexistent.js|${BASE}/assets/nonexistent.js"
"200|GET /api/health (with Origin)|-H|Origin: ${BASE}|${BASE}/api/health"
"400|POST /api/oauth/token (no body)|-X|POST|-H|Origin: ${BASE}|${BASE}/api/oauth/token"
Expand Down Expand Up @@ -93,9 +93,9 @@ TESTS=(
"403|GET /eval-stdin.php|${BASE}/eval-stdin.php"
"403|GET /.aws/credentials|${BASE}/.aws/credentials"
"403|GET /.ssh/id_rsa|${BASE}/.ssh/id_rsa"
"403,200|GET /robots.txt|${BASE}/robots.txt"
"403,200|GET /sitemap.xml|${BASE}/sitemap.xml"
"403,200|GET /favicon.ico|${BASE}/favicon.ico"
"200|GET /robots.txt|${BASE}/robots.txt"
"200|GET /sitemap.xml|${BASE}/sitemap.xml"
"200|GET /favicon.ico|${BASE}/favicon.ico"
"403|GET /random/garbage/path|${BASE}/random/garbage/path"
# Rule 2: Scanner User-Agents — normal UAs
"200|Normal browser UA|-H|User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36|${BASE}/"
Expand Down
56 changes: 35 additions & 21 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
import RepoLockControls from "../shared/RepoLockControls";
import RepoGitHubLink from "../shared/RepoGitHubLink";
import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow";
import { orderRepoGroups, ensureLockedRepoGroups } from "../../lib/grouping";
import { orderRepoGroups, ensureLockedRepoGroups, groupKeys, indexGroups, arraysShallowEqual } from "../../lib/grouping";
import { createReorderHighlight } from "../../lib/reorderHighlight";
import { createFlashDetection } from "../../lib/flashDetection";

Expand Down Expand Up @@ -193,6 +193,9 @@ export default function ActionsTab(props: ActionsTabProps) {
);
return orderRepoGroups(withLocked, lockedForTab);
});
const visibleRepoGroups = repoGroups;
const repoGroupKeys = createMemo(() => groupKeys(visibleRepoGroups()), undefined, { equals: arraysShallowEqual });
const repoGroupIndex = createMemo(() => indexGroups(visibleRepoGroups()));

createEffect(() => {
const names = activeRepoNames();
Expand Down Expand Up @@ -248,18 +251,27 @@ export default function ActionsTab(props: ActionsTabProps) {
</Show>

{/* Repo groups + locked stubs */}
<Show when={(!props.loading || props.workflowRuns.length > 0) && repoGroups().length > 0}>
<For each={repoGroups()}>
{(repoGroup) => {
const isEmpty = () => repoGroup.workflows.length === 0;
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);
<Show when={(!props.loading || props.workflowRuns.length > 0) && visibleRepoGroups().length > 0}>
<For each={repoGroupKeys()}>
{(repoName) => {
const repoGroup = () => repoGroupIndex().get(repoName) ?? { repoFullName: repoName, workflows: [] };
const isEmpty = () => repoGroup().workflows.length === 0;
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup().repoFullName);

const sortedWorkflows = createMemo(() =>
sortWorkflowsByStatus(repoGroup.workflows)
sortWorkflowsByStatus(repoGroup().workflows)
);
const workflowKeys = createMemo(
() => sortedWorkflows().map((w) => w.workflowId),
undefined,
{ equals: arraysShallowEqual },
);
const workflowIndex = createMemo(
() => new Map(sortedWorkflows().map((w) => [w.workflowId, w])),
);

const workflowCounts = createMemo(() => {
const wfs = repoGroup.workflows;
const wfs = repoGroup().workflows;
const total = wfs.length;
let passed = 0;
let failed = 0;
Expand All @@ -282,19 +294,19 @@ export default function ActionsTab(props: ActionsTabProps) {
<Show
when={!isEmpty()}
fallback={
<EmptyLockedRepoRow repoFullName={repoGroup.repoFullName} section="actions" tabKey={tabKey()} />
<EmptyLockedRepoRow repoFullName={repoGroup().repoFullName} section="actions" tabKey={tabKey()} />
}
>
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
<div class="bg-base-100" data-repo-group={repoGroup().repoFullName}>
<RepoGroupHeader
repoFullName={repoGroup.repoFullName}
repoFullName={repoGroup().repoFullName}
isExpanded={isExpanded()}
isHighlighted={highlightedReposActions().has(repoGroup.repoFullName)}
onToggle={() => toggleExpandedRepo(tabKey(), repoGroup.repoFullName)}
isHighlighted={highlightedReposActions().has(repoGroup().repoFullName)}
onToggle={() => toggleExpandedRepo(tabKey(), repoGroup().repoFullName)}
trailing={
<>
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="actions" />
<RepoLockControls repoFullName={repoGroup.repoFullName} tabKey={tabKey()} />
<RepoGitHubLink repoFullName={repoGroup().repoFullName} section="actions" />
<RepoLockControls repoFullName={repoGroup().repoFullName} tabKey={tabKey()} />
</>
}
collapsedSummary={
Expand Down Expand Up @@ -333,7 +345,7 @@ export default function ActionsTab(props: ActionsTabProps) {
</span>
}
/>
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
<Show when={!isExpanded() && peekUpdates().get(repoGroup().repoFullName)}>
{(peek) => (
<div class="animate-flash flex items-center gap-2 text-xs text-base-content/70 px-4 py-1.5 border-b border-base-300 bg-base-100">
<span class="loading loading-spinner loading-xs text-primary/60" />
Expand All @@ -346,16 +358,18 @@ export default function ActionsTab(props: ActionsTabProps) {
{/* Workflow cards grid */}
<Show when={isExpanded()}>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-3">
<For each={sortedWorkflows()}>
{(wfGroup) => {
const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`;
<For each={workflowKeys()}>
{(wfId) => {
const wfGroup = () =>
workflowIndex().get(wfId) ?? { workflowId: wfId, workflowName: "", runs: [] };
const wfKey = `${repoGroup().repoFullName}:${wfId}`;
const isWfExpanded = () => expandedWorkflows[wfKey] ?? false;

return (
<div class={isWfExpanded() ? "col-span-full" : ""}>
<WorkflowSummaryCard
workflowName={wfGroup.workflowName}
runs={wfGroup.runs}
workflowName={wfGroup().workflowName}
runs={wfGroup().runs}
expanded={isWfExpanded()}
onToggle={() => toggleWorkflow(wfKey)}
onIgnoreRun={handleIgnore}
Expand Down
Loading
Loading