Skip to content
Back to skills

Verbose

ASecurity

TLS is either configured deliberately or inherited from a distro default written years ago. Pin the protocol floor and let 1.3 negotiate its own suites.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 10, 2026
devopspythonrustgobashnodekubernetesawsapisecurity

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Scanned September 10, 2026

npx -y skills add snoodleboot-io/prompticorn --skill verbose --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Verbose?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Verbose
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/snoodleboot-io-verbose-prompticorn/badge)](https://www.skillsdirectory.com/skills/snoodleboot-io-verbose-prompticorn)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# API Security Hardening (Verbose)

## Core Patterns

### Transport Configuration

TLS is either configured deliberately or inherited from a distro default written
years ago. Pin the protocol floor and let 1.3 negotiate its own suites.

```nginx
server {
    listen 443 ssl;
    http2 on;

    ssl_protocols             TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_ciphers               ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
    ssl_session_tickets       off;      # tickets weaken forward secrecy if keys are static
    ssl_stapling              on;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    server_tokens off;                  # drop the version from Server:
}
```

Two details that bite. First, `add_header` in nginx does not inherit into a
`location` block that declares its own `add_header` — headers silently vanish on
exactly the routes people customize; `always` covers error responses but not
that inheritance rule. Second, HSTS `preload` is a one-way door: browsers ship
the list in-binary, so a subdomain that cannot do HTTPS becomes unreachable for
release cycles.

Verify from outside rather than from the config file:

```bash
nmap --script ssl-enum-ciphers -p 443 api.example.com
openssl s_client -connect api.example.com:443 -tls1_1   # must fail to negotiate
```

### Cross-Origin Policy

```python
ALLOWED = frozenset({"https://app.example.com", "https://admin.example.com"})

@app.middleware("http")
async def cors(request, call_next):
    origin = request.headers.get("origin")
    resp = await call_next(request)
    if origin in ALLOWED:                       # exact match against a frozen set
        resp.headers["Access-Control-Allow-Origin"] = origin
        resp.headers["Access-Control-Allow-Credentials"] = "true"
        resp.headers["Vary"] = "Origin"         # or a cache serves one tenant's header to another
    return resp
```

| Mistake | Why it fails |
|---|---|
| `Allow-Origin: *` with credentials | Browser refuses; teams then reflect the origin |
| Reflecting any `Origin` | Every website becomes a trusted caller of the user's session |
| `origin.endswith(".example.com")` | `evil-example.com` and `example.com.attacker.io` slip through |
| Omitting `Vary: Origin` | CDN caches the header for the wrong origin |
| `Allow-Origin: null` | Sandboxed iframes and `data:` URLs send `Origin: null` |

Note the null-origin case specifically: it is reachable from any attacker page
via a sandboxed iframe, so `null` must never be in the allowlist.

### Request Limits and Timeouts

Every unbounded dimension is a denial-of-service primitive.

```nginx
client_max_body_size    1m;
client_header_buffer_size 8k;
large_client_header_buffers 4 8k;
client_body_timeout     10s;
client_header_timeout   10s;
send_timeout            30s;
keepalive_timeout       65s;
```

Mirror these in the application — the proxy is not the only ingress path:
service-mesh sidecars, port-forwards, and internal callers hit the pod directly.

```python
@app.get("/orders")
def list_orders(limit: int = Query(50, ge=1, le=100), cursor: str | None = None):
    ...          # a caller-controlled limit with no ceiling is a full table export
```

Decompression is the asymmetric case. Accepting gzipped request bodies means a
1 MB body can expand to gigabytes; cap the *decompressed* size and abort mid-
stream rather than trusting the compressed length.

### Rate Limiting and Quotas

Run the counter in a shared store, evaluated atomically — a per-instance
in-memory limiter multiplies the effective limit by your replica count, so a
"100 r/s" rule admits 1000 r/s across ten pods. In Redis, that means a Lua
script or `INCR` + `EXPIRE` in one round trip, never read-then-write.

Respond so clients can cooperate:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 30
```

Choose the key by what you are protecting: API key or user id for quota
fairness, IP for anonymous floods, and the *account* for credential attacks.
Behind a proxy, `$remote_addr` is the proxy — key on a trusted-parsed
`X-Forwarded-For` and count from the right, or all clients share one bucket.

### Runtime and Egress Isolation

```yaml
securityContext:
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities: { drop: ["ALL"] }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-egress }
spec:
  podSelector: { matchLabels: { app: api } }
  policyTypes: ["Egress"]
  egress:
    - to: [{ podSelector: { matchLabels: { app: postgres } } }]
    - to: [{ namespaceSelector: { matchLabels: { name: kube-system } } }]
      ports: [{ port: 53, protocol: UDP }]
```

The default `Egress: {}` in Kubernetes is allow-all; a NetworkPolicy with an
empty egress list denies everything, including DNS — which is why the resolver
rule above is explicit and why the first attempt at this always breaks name
resolution. On AWS, also require IMDSv2 (`HttpTokens: required`) and set the hop
limit to 1 so a container cannot reach the node's credentials.

## Common Anti-Patterns

❌ **Reflecting the `Origin` header with `Allow-Credentials: true`.**
✅ Exact-match allowlist plus `Vary: Origin`.

❌ **Limits only at the CDN or ingress.**
✅ Enforce body size, timeouts, and pagination caps in the service itself.

❌ **Rate limiting on `$remote_addr` behind a load balancer.**
✅ Parse `X-Forwarded-For` from the right, past trusted proxy hops.

❌ **`429` with no `Retry-After`** — clients hot-loop and amplify the incident.
✅ Return `Retry-After` and `RateLimit-*` headers.

❌ **Copying an HTML security-header bundle onto a JSON API** — a long CSP on
`application/json` is cargo cult.
✅ `nosniff`, `no-store`, and `default-src 'none'` are the ones that earn their bytes.

❌ **Allow-all egress from application pods.**
✅ Deny by default; enumerate the destinations, DNS included.

## Hardening Checklist

- [ ] TLS 1.2 minimum; 1.0/1.1 and 3DES/RC4 confirmed unavailable externally
- [ ] HSTS `max-age >= 1 year`; `preload` only after subdomain audit
- [ ] CORS origins exact-matched from a static set; `null` never allowed
- [ ] `Vary: Origin` on every CORS response
- [ ] Body, header, nesting-depth, and decompressed-size limits set
- [ ] Read/write/idle timeouts configured at proxy and application
- [ ] Pagination has a server-side maximum the client cannot raise
- [ ] Rate limits keyed by principal and by IP, with `Retry-After` on 429
- [ ] `X-Content-Type-Options: nosniff` and `Cache-Control: no-store` on API responses
- [ ] `Server`/`X-Powered-By` version strings suppressed
- [ ] Containers non-root, read-only rootfs, `capabilities: drop: [ALL]`
- [ ] Default-deny egress NetworkPolicy with DNS explicitly permitted
- [ ] Cloud metadata service requires session tokens (IMDSv2) with hop limit 1
- [ ] Admin, metrics, and profiling endpoints on a non-public listener
- [ ] TLS and header configuration verified by an external scan in CI, not by review

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…