Penetration-test plan
Status: Plan only. Nothing here has been run. Driven by threat-model.md: each test names the boundary (TB) it attacks, and a result that contradicts the model’s “Mitigation” column is a finding. Tracked in #66.
1. Rules of engagement
Section titled “1. Rules of engagement”- Test only the dev stack (
deploy/compose/setup.sh) or a deployment you own. Never someone else’s compose project or a shared database. - Use the dev CA with
curl --cacert deploy/compose/.state/internal-ca.pem. Do not bypass certificate warnings or change a trust store. - Dev users come from
deploy/compose/config/dev-users.toml; uselimen_testkit::usersin automated tests. - Run the workers and targets in compose. Anything that needs real AWS, Okta, or a real CI provider is out of scope here (see the
Verify Phase Nissues). - File one Bug per confirmed finding, labelled
security, with the boundary, the steps, and the expected versus observed result. Report a vulnerability that works against a released build privately first. - Stop on any sign of impact outside the dev stack (traffic leaving the machine, a changed host setting).
2. Attack first
Section titled “2. Attack first”If time is short, run these in order. Each one, if it fails, gives an attacker the keys or hides the evidence.
- Certificate issuance: get a certificate for another user or role (token replay, key swap, group-mapping edge cases, bot/person confusion).
- Grant and policy: open a resource the role does not allow (forged or replayed grant, label or name look-alikes, stale snapshot, a role or database other than the granted one).
- Fabric trust: act as a proxy or worker (join token reuse, header spoofing, a worker certificate on proxy-only calls).
- Protocol checks: slip a denied command past the worker (framing tricks, pipelining, encoded paths,
/k8s/identity headers). - Audit integrity: act without leaving an event, or alter one afterwards (spool tampering, full disk, recording swap).
- Console: CSRF, XSS, and cross-user reads.
- Deployment config: over-broad IAM, KMS, and bucket policies, and exposed admin ports.
Tools: curl/openssl s_client and the limen CLI for 1 to 3, cargo-fuzz and hand-built frames for 4, limen-audit-verify for 5, a browser proxy (own profile, dev CA) for 6.
3. Tests by area
Section titled “3. Tests by area”Each row is a test case. “Expect” is what the threat model promises. Rows marked † go beyond what the model covers today: a failure is a finding and a threat-model gap.
Login and tokens (TB0, TB8)
Section titled “Login and tokens (TB0, TB8)”| Test | Expect |
|---|---|
| Replay an ID token, an authorization code, and a device-flow token | Second use refused (nonce and jti single-use) |
| Swap the public key in the cert request after login | Refused: the nonce binds the key hash |
Tamper with state, drop PKCE, downgrade to plain |
Refused |
Forge a bot token: wrong issuer, audience, alg: none, key confusion, expired, far-future iat |
Refused; no certificate issued |
Log in as bot: names from a person’s IdP name |
Refused (reserved prefix) |
† Poisoned OIDC discovery or JWKS: issuer-URL tricks (SSRF to internal hosts), kid confusion, key rotation mid-login, JWKS fetch failure |
Only the configured issuer is fetched; fails closed |
† Name and group edge cases: case and Unicode look-alikes, limen:// or bot: inside names, huge or duplicate group claims |
One identity per user; no role or SAN injected from a claim |
| † Bot tokens from forks, pull-request refs, or other branches that match a broad repository or ref rule | Too-broad matchers rejected by policy validation; denied login audited |
Exceed the login rate limit from one IP; many IPs with X-Forwarded-For |
Limited per peer IP; header ignored |
| Search all logs for tokens, cookies, and keys after the above | None found (Secret<T>; log scanner test) |
Tunnel enrollment and the fabric (TB2, TB9)
Section titled “Tunnel enrollment and the fabric (TB2, TB9)”| Test | Expect |
|---|---|
| Reuse, race, and expire a join token; use a worker token to request a proxy DNS name | Single use, atomic; kind-scoped |
Present a worker certificate to proxy-only calls (forwarding, peering, AuditHandoff) |
Refused |
Send x-limen-*, Impersonate-*, X-Remote-* headers from a client |
Stripped or refused |
| Forge, replay, and expire a session grant; alter the worker name or session id | Refused (signature, 60 s TTL, nonce, binding) |
| Roll back to an older policy snapshot; stop the control plane past the grace period | Rollback refused; fail closed after grace |
| Claim labels not in the worker certificate | Ignored; routing uses certified labels only |
| † Evict the grant nonce cache with many grants, then replay one inside its 60 s life | Refused (grant bound to session and worker) |
| † Enrollment CSR with extra SANs, extensions, or another component’s name; name squatting with a static token | Identity comes from the control plane, not the CSR |
| † Relay loops and open relay: proxy-to-proxy hops that bounce, asking a proxy to reach another worker’s tunnel | Hop limit; peer ID pinned from the registry |
Proxy listener (TB1)
Section titled “Proxy listener (TB1)”| Test | Expect |
|---|---|
| TLS 1.2, bad ALPN, no client cert, expired or other-CA cert | Refused |
| Slowloris, handshake floods, half-open connections, many streams per tunnel | Pre-auth limits and caps hold; the proxy stays responsive |
Oversize, truncated, and unknown-field SessionRequest and gRPC messages |
Rejected with no panic |
Use another user’s certificate after limen admin lock or kill-session |
Refused at login, issuance, proxy, and worker |
| † HTTP/2 and gRPC abuse before auth: rapid reset, header and metadata floods, CONTINUATION floods, slow reads | Bounded; the proxy stays responsive |
| † Wrong ALPN to reach another service; HTTP/1.1 without ALPN | Routed only to what the certificate and ALPN allow |
Policy bypass (TB2, TB3)
Section titled “Policy bypass (TB2, TB3)”| Test | Expect |
|---|---|
| Open each resource kind with a role that lacks it; wildcard and deny-rule edge cases; label and name look-alikes (case, Unicode, trailing dots) | Denied; deny wins |
| Ask for a role, database, login, or namespace other than the granted one in each protocol | Refused at the worker |
| Race a policy change against open sessions | Next check applies the change |
| † Lock or kill a user while a session is opening and during a policy refresh | Session ends; no window that opens a new one |
Recording and audit integrity (TB6)
Section titled “Recording and audit integrity (TB6)”| Test | Expect |
|---|---|
Edit, delete, reorder, and truncate spool files and shipped batches; then run limen-audit-verify (runbook) |
Chain break detected |
| Fill the spool and the disk; kill components mid-write | Fail closed; no silent loss; seq gaps visible |
| Modify a recording, then play it in the console | Hash mismatch refused |
| Put secrets in statements, headers, and bodies; inspect audit and recordings | Matches the redaction rules per protocol |
| † Emit events as another producer, user, or session from a worker; control characters and newlines in fields | Attribution comes from the certificate; control characters stripped |
| † Delete the newest batches and manifests, then verify | Detected, or recorded as a gap (a chain alone cannot show a missing tail without an anchor) |
† Recording upload with ../, another session’s id, or a second upload |
Refused; one write per session |
Console (TB7)
Section titled “Console (TB7)”| Test | Expect |
|---|---|
POST without the CSRF token, with a wrong Origin/Referer, cross-site |
Refused |
| Stored and reflected XSS through usernames, resource names, labels, session text, and recording content | Escaped; CSP blocks inline script |
| Read other users’ sessions and recordings by id; reach admin pages as a non-admin | 404 and 403, enforced server side |
| Fix, steal, and replay cookies; use a cookie after lock or logout | Rejected |
| † Open redirect and Host-header tricks on login callback, handoff, and links | Only the configured public origin is used |
| † SQL or filter injection and unbounded paging in session, audit, and recording search | Literal SQL; limits capped |
| † Terminal escape sequences (OSC title, clipboard) and HTML in played-back recordings | Rendered as data; no script, no clipboard write |
Protocol parsers (TB3)
Section titled “Protocol parsers (TB3)”Fuzz, then hand-craft hostile input, for every parser that reads bytes from a client or target: PostgreSQL and MySQL frames, RESP, BSON and OP_MSG, AMQP, Kafka, the HTTP paths for search, Kubernetes (/k8s/, upgrade handling, encoded paths), web apps, and SSH. Expect no panic, bounded memory, no desynchronization that lets a command skip the check, and the frame caps in the threat model.
- † Use each protocol’s dangerous features through an allowed role; expect denial unless granted:
- SQL:
COPY ... PROGRAM,pg_read_server_files,lo_import,SET ROLE,LOAD DATA LOCAL INFILE,INTO OUTFILE. - Redis:
EVAL,MODULE LOAD,REPLICAOF,MIGRATE,CONFIG SET,DEBUG. - MongoDB:
$where,$function,$out/$merge, another session’s cursor. - Search: scripts, remote
_reindex,_snapshot,_tasks. - AMQP and Kafka: admin operations, other users’ topics, queues, and groups.
- SSH: port, agent, and X11 forwarding,
env, thesftpsubsystem. - Kubernetes:
pods/proxy,nodes/proxy, other namespaces,portforwardto any port.
- SQL:
- Fuzzing with
cargo-fuzzis promised in ADR-0012 and design §13 but is not in the repository yet. Add targets for the MySQL codec, the PostgreSQL frame splitter, and the BSON reader first.
Kubernetes route (TB1, ADR-0038)
Section titled “Kubernetes route (TB1, ADR-0038)”| Test | Expect |
|---|---|
Send Authorization, Impersonate-*, X-Remote-*, X-Limen-* to /k8s/<cluster>/ |
Refused or stripped; the user comes from the certificate |
Reach other verbs through exec/attach/portforward upgrades; encoded paths like %73ecrets |
Refused |
Exec without a recordable stream (base64.* subprotocol, extensions) |
Refused or recorded |
Web apps and TCP (TB10)
Section titled “Web apps and TCP (TB10)”| Test | Expect |
|---|---|
Replay a handoff at another host or resource; open a WebSocket with a foreign Origin |
Refused |
Spoof Host, SNI, and :authority; set identity headers in requests |
Mismatch refused; headers stripped |
Ask limen tcp for an address other than the resource’s |
Worker connects only to the fixed address |
| † Request smuggling between proxy, worker, and upstream (CL/TE, HTTP/2 downgrade); header injection through paths | One request in, one request out; invalid framing refused |
CLI and the user’s machine (TB1)
Section titled “CLI and the user’s machine (TB1)”| Test | Expect |
|---|---|
† Another local user races to bind the loopback port, or connects to the listener or --socket |
Known risk; socket mode 0600, loopback only |
† Symlink and race attacks on ~/.limen, detached-connection state and log files |
0600, symlinks not followed |
† Resource names or labels with newlines or spaces reaching limen config ssh, kubeconfig, or known_hosts |
Rejected by name validation |
† Installer and update path (deploy/cli): piped shell, unsigned downloads |
Checksum or signature verified |
Deployment and cloud config (review, no live attack)
Section titled “Deployment and cloud config (review, no live attack)”Read the Terraform, Helm, and ECS/EC2 definitions as an attacker would: worker role limited to dbuser:<id>/limen_*; kms:Sign only for the control plane with the algorithm condition and one key per purpose; S3 bucket policy denies delete, uses SSE-KMS and Object Lock; control plane and Aurora only reachable from proxies; workers have egress only; admin ports (/healthz, /readyz, metrics) not public; containers non-root with no secrets in env or logs; dev-only settings (insecure_dev, anonymous Grafana, open Trino, test-hooks) cannot reach a release.
Supply chain and CI (section 5)
Section titled “Supply chain and CI (section 5)”Run cargo deny --locked check and cargo audit; confirm Actions are pinned to commit SHAs and workflows use minimal permissions:; confirm release images are built without the test-hooks feature.
4. Code review, crate by crate
Section titled “4. Code review, crate by crate”Review in this order, because each crate’s trust is built on the one before. For each: unwrap/expect/indexing on untrusted input, unchecked arithmetic on lengths, unbounded allocation, missing deny_unknown_fields, secrets without Secret<T>, authorization done after an effect, and constant-time comparison of secrets.
limen-core,limen-auth,limen-ca,limen-policy: OIDC and bot login, identity parsing, certificate issuance, policy evaluation.limen-session,limen-tunnel,limen-proto: grants, tunnel, wire schema.limen-control-plane: login, enrollment, console, API authorization.limen-proxy: listener, routing, header handling, peering.limen-worker,limen-kube,limen-component: protocol modules (largest attack surface).limen-audit: spool, chain, manifests.limen-cli: local listeners, credential storage, detached connections.
5. Output and done
Section titled “5. Output and done”- One Bug per finding (severity, boundary, reproduction, suggested fix).
- Update
threat-model.md§7 (review log) and §6 (residual risks) for anything accepted rather than fixed. - Add a regression test for every fixed finding, in
limen-e2eor the crate’s tests, so it runs in CI. - Done when every row in section 3 has a recorded result (pass, finding, or not applicable with a reason) and every crate in section 4 has been reviewed. Record both in the issue.