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
103 changes: 103 additions & 0 deletions CLA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Contributor License Agreement

> **This is a template and has not been reviewed by a lawyer.** It follows the
> structure of the widely used Apache Individual CLA, but you should have it
> reviewed by qualified counsel before relying on it. Whoever adopts it is
> responsible for its adequacy in their jurisdiction.

## Why this exists

Forms WAF is MIT licensed today. This agreement grants Dobrev IT Ltd a licence
broad enough to relicense or dual-license the project in future — for example
under an open-core model — without having to locate and obtain permission from
every past contributor.

**You keep the copyright in your contribution.** This is a licence grant, not an
assignment. You remain free to use your own work however you wish, including in
other projects.

If you would rather not sign, that is entirely reasonable. Please open an issue
describing what you want to change; a maintainer may be able to implement it
independently.

---

## Agreement

By submitting a Contribution to this project, You accept and agree to the
following terms for Your present and future Contributions.

**1. Definitions**

"You" means the copyright owner, or the legal entity authorised by the copyright
owner, entering into this agreement.

"Contribution" means any original work of authorship, including any
modification of or addition to an existing work, that is intentionally submitted
by You to the project for inclusion in or documentation of any of its products.
"Submitted" means any form of electronic, verbal or written communication sent
to the project or its maintainers, including but not limited to pull requests,
issues and electronic mailing lists, excluding communication conspicuously
marked or otherwise designated in writing by You as "Not a Contribution".

**2. Grant of Copyright Licence**

You grant to Dobrev IT Ltd and to recipients of software distributed by it a
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright licence to reproduce, prepare derivative works of, publicly display,
publicly perform, sublicense and distribute Your Contributions and such
derivative works, **under any licensing terms, including proprietary terms**.

**3. Grant of Patent Licence**

You grant to Dobrev IT Ltd and to recipients of software distributed by it a
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent licence to make, have made, use, offer
to sell, sell, import and otherwise transfer the Work, where such licence
applies only to those patent claims licensable by You that are necessarily
infringed by Your Contribution alone or by combination of Your Contribution with
the Work.

If any entity institutes patent litigation alleging that Your Contribution, or
the Work to which You contributed, constitutes direct or contributory patent
infringement, then any patent licences granted to that entity under this
agreement terminate as of the date such litigation is filed.

**4. Your Representations**

You represent that You are legally entitled to grant the above licences. If Your
employer has rights to intellectual property that You create, You represent that
You have received permission to make the Contribution on behalf of that
employer, that Your employer has waived such rights, or that Your employer has
executed a separate corporate CLA.

You represent that each Contribution is Your original creation, and that Your
Contribution submissions include complete details of any third-party licence or
other restriction of which You are personally aware and which is associated with
any part of Your Contribution.

**5. No Warranty**

You are not expected to provide support for Your Contributions, except to the
extent You desire to do so. Contributions are provided "AS IS", without
warranty of any kind, express or implied, including any warranty of
merchantability or fitness for a particular purpose.

**6. Notification**

You agree to notify the project of any facts or circumstances of which You become
aware that would make these representations inaccurate in any respect.

---

## How to sign

Comment on your first pull request with:

```
I have read the CLA document and I hereby sign the CLA.
```

Include the name and email address you use for commits. A maintainer will record
it. If you are contributing on behalf of an employer, say so — a corporate CLA
may be needed instead.
87 changes: 87 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Contributing

Thanks for considering a contribution.

## Before you start

For anything beyond a small fix, open an issue first. This is a security product
with a defence engine whose scoring is calibrated against real traffic patterns,
and a change that looks harmless can shift the boundary between "flag" and
"block" for every deployment. Discussing the shape of a change first saves
rework.

**Security issues do not belong in pull requests or public issues.** See
[SECURITY.md](SECURITY.md).

## Development

The stack runs under Docker Compose; see [README.md](README.md). `CLAUDE.md`, if
present in your checkout, carries working notes on the architecture.

```bash
docker compose up -d --build
docker compose exec redis sh /init-data.sh # seed defaults
```

## The checks your change must pass

CI runs these on every pull request, and all of them block:

```bash
# Lua unit tests (busted, on LuaJIT -- see openresty/spec/README.md)
docker build -t forms-waf-lua-test -f openresty/Dockerfile.test ./openresty
docker run --rm -v "$PWD/openresty/lua:/app/lua:ro" \
-v "$PWD/openresty/spec:/app/spec:ro" \
forms-waf-lua-test --verbose /app/spec

# Admin UI
cd admin-ui && npm ci && npm run typecheck && npm run lint && npm run audit:gate

# Integration suite (needs a running stack, and admin credentials so it can
# raise the rate limits it would otherwise trip)
WAF_ADMIN_USER=admin WAF_ADMIN_PASS=... ./scripts/test-waf.sh

# API contract, against a running stack
python3 scripts/check-api-contract.py
```

Syntax-check Lua with **LuaJIT**, not the system `luac`. Several modules use
`goto`/`::continue::`, which PUC Lua 5.1 rejects and Lua 5.4 accepts — neither
matches the runtime.

## Things that are easy to get wrong

These have each caused a shipped defect, so they are worth knowing up front.

**A new endpoint config key must be added to `config_resolver.resolve()`.** That
function builds its result from an explicit allowlist, and anything not named
there is silently dropped before the request path sees it. Three features were
inert for exactly this reason. `openresty/tests/config_contract_spec.lua` guards
the seam — add your key to it.

**A new Admin API route needs an RBAC entry.** `rbac.check_permission()`
default-denies any route with no mapping, so a registered-but-unmapped handler
returns 403 for every role including admin. The startup audit will log the gap;
do not ignore it.

**A change to an API response shape needs the OpenAPI spec updated.**
`docs/openapi.yaml` is validated against a live server, and the admin UI's types
are generated from it. Both directions are enforced.

**Defaults are a security decision.** Shipping something permissive "so the
tests pass" is how the allowlist ended up disabling the WAF. If a test and a
default disagree, work out which one is wrong.

## Commits and pull requests

- `type: Subject` — `feat:`, `fix:`, `docs:`, `test:`, `chore:`, `ci:`
- Explain *why*, not just what. A commit that says what the diff already says is
a wasted opportunity.
- Say what you verified, and how. "Tests pass" is weaker than the numbers.
- Branch from `main`, open a PR against `main`.

## Contributor License Agreement

Contributions require a signed CLA — see [CLA.md](CLA.md). This lets the project
relicense or dual-license in future without tracking down every past
contributor. You keep the copyright in your contribution.
53 changes: 53 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Security Policy

## Reporting a vulnerability

**Please do not open a public issue for security problems.**

Report privately through either channel:

- GitHub's [private vulnerability reporting](https://github.com/dobrevit/forms-waf/security/advisories/new) (preferred — it keeps the discussion attached to the repository)
- Email **security@dobrev.eu**

Please include enough detail to reproduce: affected version or commit, configuration relevant to the issue, and the steps or request that triggers it. A proof of concept helps, but a clear description is enough to get started.

### What to expect

| Stage | Target |
|---|---|
| Acknowledgement | 3 working days |
| Initial assessment | 10 working days |
| Fix or mitigation for a confirmed high-severity issue | 30 days |

If a report is disputed, we will explain the reasoning rather than closing it silently. If a fix will take longer than the target, we will say so and why.

## Scope

In scope:

- The WAF request path (`openresty/lua/`) — bypasses, request smuggling, injection, denial of service
- The Admin API and its authentication, session handling and RBAC
- The admin UI (`admin-ui/`)
- Default configuration shipped in `redis/init-data.sh` and `helm/`
- The Helm chart and container images

Out of scope:

- Vulnerabilities in a deployer's own upstream application
- Findings that require a misconfiguration explicitly warned against in the documentation
- Automated scanner output with no demonstrated impact
- Denial of service through sheer traffic volume against an under-provisioned deployment

## A note on defaults

This is a WAF, so its shipped defaults are part of its security posture. Reports
about defaults that weaken protection are in scope and welcome — a previous
release seeded an IP allowlist covering every RFC1918 range, which disabled
inspection entirely in the topology the product targets. That class of issue
matters as much as a code-level bug.

## Disclosure

We aim to publish an advisory once a fix is available, crediting the reporter
unless anonymity is requested. If you intend to publish independently, please
give us a reasonable window to ship a fix first.
2 changes: 2 additions & 0 deletions admin-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import AttackSignatures from '@/pages/security/AttackSignatures'
import AttackSignatureEditor from '@/pages/security/AttackSignatureEditor'
import BehavioralAnalytics from '@/pages/analytics/BehavioralAnalytics'
import ClusterStatus from '@/pages/cluster/ClusterStatus'
import ShadowMode from '@/pages/shadow/ShadowMode'
import { About } from '@/pages/About'
import { Users } from '@/pages/admin/Users'
import { AuthProviders } from '@/pages/admin/AuthProviders'
Expand Down Expand Up @@ -87,6 +88,7 @@ export default function App() {
<Route path="/security/attack-signatures" element={<AttackSignatures />} />
<Route path="/security/attack-signatures/:id" element={<AttackSignatureEditor />} />
<Route path="/analytics/behavioral" element={<BehavioralAnalytics />} />
<Route path="/security/shadow" element={<ShadowMode />} />
<Route path="/cluster" element={<ClusterStatus />} />
<Route path="/about" element={<About />} />
<Route path="/admin/users" element={<Users />} />
Expand Down
95 changes: 95 additions & 0 deletions admin-ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1409,3 +1409,98 @@ export const backupApi = {
return backup
},
}

// ---------------------------------------------------------------------------
// Shadow mode
//
// What monitoring mode *would* have blocked. The point of these is to let an
// operator see the consequences of a rule set before it starts rejecting real
// traffic, then promote it once the sample looks right.
// ---------------------------------------------------------------------------

export interface ShadowCount {
name: string
count: number
}

export interface ShadowDecision {
ts: number
vhost_id: string
endpoint_id: string
client_ip?: string
host?: string
path?: string
method?: string
score: number
blocked_by?: string[]
flags?: string[]
}

export interface ShadowScope {
vhost_id: string
endpoint_id: string
would_block_count: number
}

export interface ShadowSummary {
would_block_total: number
// Non-zero means the recorder's buffer overflowed and every count below
// understates reality.
dropped_total: number
retained_decisions: number
top_rules: ShadowCount[]
top_flags: ShadowCount[]
scopes: ShadowScope[]
}

export interface ShadowImpact {
vhost_id: string
endpoint_id?: string
would_block_count: number
unique_client_ips: number
average_score: number
window_start?: number
window_end?: number
sample_incomplete: boolean
dropped_total?: number
top_rules: ShadowCount[]
top_flags: ShadowCount[]
affected_endpoints: ShadowCount[]
}

export interface ShadowPromoteResponse {
promoted: boolean
vhost_id: string
mode?: string
previous_mode?: string
message?: string
}

export const shadowApi = {
summary: () => request<ShadowSummary>('/shadow/summary'),

decisions: (params?: { limit?: number; vhost_id?: string; endpoint_id?: string }) => {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.vhost_id) q.set('vhost_id', params.vhost_id)
if (params?.endpoint_id) q.set('endpoint_id', params.endpoint_id)
const qs = q.toString()
return request<{ decisions: ShadowDecision[]; count: number; limit?: number }>(
`/shadow/decisions${qs ? `?${qs}` : ''}`
)
},

impact: (vhostId: string, endpointId?: string) => {
const q = new URLSearchParams({ vhost_id: vhostId })
if (endpointId) q.set('endpoint_id', endpointId)
return request<ShadowImpact>(`/shadow/impact?${q.toString()}`)
},

promote: (vhostId: string) =>
request<ShadowPromoteResponse>('/shadow/promote', {
method: 'POST',
body: JSON.stringify({ vhost_id: vhostId }),
}),

clear: () => request<{ cleared: boolean }>('/shadow/decisions', { method: 'DELETE' }),
}
Loading
Loading