# Security Documentation

## 1. Security Overview

The Packr registry has been hardened against the OWASP Top 10 and common npm registry attack vectors, including tarball path traversal, package hijacking, brute-force credential attacks, webhook SSRF, and denial-of-service via large uploads or high request volume. This document describes the security controls in place.

---

## 2. Authentication & Authorization

| Endpoint | Auth Method | Description |
|---|---|---|
| `/-/v1/login` | None (public) | User login / registration (disabled by default) |
| `PUT /` (publish) | JWT Bearer token | Package publishing |
| `/-/v1/deprecate` | JWT Bearer token | Deprecate package versions |
| `/-/v1/unpublish` | JWT Bearer token | Unpublish package versions |
| `/api/v1/admin/sessions` | `X-Internal-Secret` header | Create admin session (Next.js -> Go) |
| `/api/v1/admin/*` | Session cookie or Bearer token | Admin dashboard API |
| `/-/search/`, `/agent/` | None (public) | Package search and agent endpoints |

---

## 3. Rate Limiting

| Route | Limit | Window | Purpose |
|---|---|---|---|
| `/-/v1/login` | 10 req | 1 min / IP | Prevent brute-force |
| `/-/v1/password-reset` | 10 req | 1 min / IP | Prevent abuse |
| `PUT /` (publish) | 30 req | 1 min / IP | Prevent flooding |
| `/api/v1/admin/*` | 60 req | 1 min / IP | Prevent admin abuse |
| `/-/search/`, `/agent/` | 30 req | 1 min / IP | Prevent DoS |

IP extraction (`internal/ratelimit/ratelimit.go:191`) reads `Fly-Client-IP`
first, then `X-Forwarded-For`, then `RemoteAddr`.

> ### ⚠️ These headers are not trustworthy here
>
> An earlier revision of this document called `Fly-Client-IP` "unforgeable
> behind the Fly.io proxy". **Fly was decommissioned on 2026-09-09**; the
> registry runs on Hetzner app-3 behind Caddy + Cloudflare. Nothing strips or
> overwrites a client-supplied `Fly-Client-IP`, so a direct caller can set it
> and receive a **fresh rate-limit bucket per request** — the limits in the
> table above are bypassable by one header.
>
> The `X-Forwarded-For` fallback does not rescue it: it takes
> `parts[len(parts)-1]`, the **rightmost** entry, which is the value appended
> by the *closest* proxy rather than the originating client. Behind a
> Caddy + Cloudflare chain that is Caddy's own view, so every request through
> the proxy can share a single key.
>
> This is a code defect, not a documentation one. It is recorded here so the
> doc stops asserting a protection the registry does not have; the fix is a
> separate change and needs care — trusting the wrong end of `X-Forwarded-For`
> would collapse every client into one bucket and break rate limiting
> fleet-wide.

---

## 4. Resource Limits

| Resource | Limit | Configurable |
|---|---|---|
| Tokens per user | 20 (free) / 50 (solo) / 200 (team) / 1000 (enterprise) | Per-plan function `maxTokensPerUser`; `PACKR_MAX_TOKENS_PER_USER` or `PACKR_MAX_TOKENS_<PLAN>`, hard-capped at 1000. Gates **minting only** — lowering it never revokes existing tokens |
| Webhooks per user | 10 | Code constant (`maxWebhooksPerUser`) |
| Tarball size | 5 MB | `MAX_TARBALL_SIZE` env var |
| Request body (login/admin) | 1 MB | `SmallBodyLimit` |
| Request body (publish) | 50 MB | `LargeBodyLimit` |
| JWT token expiry | 30 days (90 via the OAuth device flow) | Code constant; no `expires_at` column, so no API reports the deadline |
| Session expiry | 7 days | Code constant |

---

## 5. Password Requirements

Passwords are validated on registration and password reset. Requirements:

- Minimum 10 characters
- Maximum 128 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character (`!@#$%^&*` etc.)

---

## 6. Input Validation

| Input | Validation | Purpose |
|---|---|---|
| Username | 3-64 chars, alphanumeric + `._-`, must start/end alphanumeric | Prevent injection |
| Package scope | Must start with `@`, lowercase alphanumeric + `._-` | Prevent path traversal |
| Package name | Lowercase alphanumeric + `._-`, start with alphanumeric | Prevent path traversal |
| Version | Strict semver (`X.Y.Z` or `X.Y.Z-prerelease`) | Prevent path traversal |
| Tarball path | Containment check — must stay within data directory | Prevent filesystem escape |

---

## 7. Package Ownership

- **First publish:** any authenticated user with a valid token can publish a new package.
- **Subsequent publishes:** only package owners can publish new versions.
- Ownership is enforced on: publish, deprecate, unpublish, and dist-tag changes.
- Package owners are set automatically on first publish.

---

## 8. Session Security

- Sessions are HMAC-SHA256 signed tokens.
- Session cookies are set with `HttpOnly` and a 7-day `MaxAge`. The other two
  flags are **not** uniform, and an earlier revision of this document said they
  were:

  | Cookie | `sameSite` | `secure` |
  |---|---|---|
  | `api/auth/token/route.ts:13` | `lax` | `NODE_ENV === 'production'` |
  | `api/auth/callback/forgeauth/route.ts:35` | `lax` | always `true` |
  | `api/auth/callback/route.ts:66` | `strict` | `NODE_ENV === 'production'` |
  | `api/auth/device/callback/route.ts:54` | `strict` | `NODE_ENV === 'production'` |

  The two `lax` cookies are the ones a cross-site redirect lands on, so they
  are the deliberate ones; the other two sit behind a same-site POST callback.
  Note that in a non-production build `secure` is **off** on three of the four —
  the site is only safe on HTTPS because production sets `NODE_ENV`.
- Sessions expire after 7 days.
- Session creation requires the `INTERNAL_API_SECRET` shared between the Next.js dashboard and the Go backend.

---

## 9. CORS Policy

| Route | CORS Origin |
|---|---|
| `/api/v1/admin/*` | `DASHBOARD_ORIGIN` only; when unset, `registry.go:175` falls back to a hardcoded `https://packr.blueforge.studio` |
| All other routes | `*` (wildcard — required for npm CLI compatibility) |

---

## 10. Webhook Security

- Payloads are signed with HMAC-SHA256 and delivered with the header `X-Packr-Signature: sha256=...`.
- SSRF protection is applied at both DNS resolution and TCP connect time, blocking private IP ranges.
- Webhooks targeting unresolvable hostnames are blocked.
- Webhook secrets are redacted in list API responses and are only shown at creation time.
- Webhook delivery has a 10-second timeout.

---

## 11. Infrastructure Security

- The Docker container runs as a non-root user (`packr`).
- HTTP server timeouts: `ReadTimeout` 30s, `WriteTimeout` 120s, `IdleTimeout` 60s.
- `ReadHeaderTimeout` is set to 10s; `MaxHeaderBytes` is limited to 1 MB.
- Token hashing uses full SHA-256 with no truncation.
- Secrets (`JWT_SECRET`, `SESSION_SECRET`) are generated randomly at startup if not configured, with a warning logged. Random secrets are ephemeral and do not persist across restarts.

---

## 12. Environment Variables

| Variable | Required | Default | Description |
|---|---|---|---|
| `JWT_SECRET` | Yes (prod) | Random (ephemeral) | JWT token signing key |
| `SESSION_SECRET` | Yes (prod) | Random (ephemeral) | Session HMAC signing key |
| `INTERNAL_API_SECRET` | Yes (dashboard) | Empty (disabled) | Shared secret for session creation |
| `ALLOW_REGISTRATION` | No | `false` | Enable self-registration |
| `AUTH_SESSION_READS_REGISTRY` | No | `false` | Whether a password login session satisfies the **read** gate (private-package reads for the dashboard). `false` (dashboard-only): reading a private package requires a stored token row. |
| `AUTH_SESSION_WRITES_REGISTRY` | No | `false` | Whether a password login session may hit the **write** planes (publish, unpublish). Split from the read flag by the 2026-10 permissions audit ([`docs/audit-2026-10-permissions.md`](docs/audit-2026-10-permissions.md)) — the read flag's name said reads while it granted writes too, no consumer ever published with a session, and the side effect was namespace squatting between authenticated users. Keep false; publishing is token-only. |
| `DASHBOARD_ORIGIN` | No | *(empty)* | Allowed CORS origin for admin API. Also used to build links in `device_flow.go:120` (`/cli/auth`), `password_reset.go:67`, and `admin_config.go:85`, so leaving it empty yields relative URLs in device-flow and password-reset emails even though CORS still works via the hardcoded fallback |
| `MAX_TARBALL_SIZE` | No | `5242880` (5 MB) | Max tarball upload size in bytes |
| `RATE_LIMIT_LOGIN` | No | `10` | Login attempts per minute per IP |
| `RATE_LIMIT_PUBLISH` | No | `30` | Publish requests per minute per IP |

---

## 13. OAuth Device Flow Security

The OAuth device flow (RFC 8628) is implemented with the following security controls:

| Control | Detail |
|---------|--------|
| **Code expiry** | Device codes and user codes expire after **15 minutes**. This limits the phishing window. |
| **Rate limiting** | `POST /-/v1/device/authorize` is rate-limited to **5 requests/min/IP** to prevent code enumeration. |
| **Single-use codes** | Once a device code is exchanged for a token, it is immediately invalidated. Replaying the code returns `expired_token`. |
| **State parameter** | Each authorization request includes a random 32-byte state token to prevent CSRF. |
| **Account linking by email** | OAuth identities are linked to Packr accounts by verified email address. Accounts are not created for unverified emails. |

---

## 14. Keychain Credential Storage

The CLI can store tokens in the system keychain rather than a plain file:

- **macOS:** macOS Keychain (encrypted, locked to user session)
- **Windows:** Windows Credential Manager
- **Linux:** libsecret (GNOME Keyring, KWallet)

Keychain storage is recommended for developer workstations. The file-based store (`~/.packr/credentials.json`) is created with mode `0600` and is acceptable for CI environments where keychain access is unavailable.

---

## 15. Scope-Based Access Control

Tokens carry optional scope restrictions in their JWT claims. When scope restrictions are present:

- The token can only be used for packages within the listed scopes.
- Attempts to publish to, unpublish from, or deprecate packages outside the listed scopes are rejected with `403 Forbidden`.
- Read operations (install, metadata fetch) respect scope restrictions for private scopes.

Empty scope list means the token applies to all packages (backward compatible with tokens created before the permission system).

---

## 16. Token Permission Enforcement

**There is no `RequirePermission` middleware on any route.** The function
exists at `internal/auth/permissions.go:125` and has unit tests, but nothing
mounts it. Each handler calls `auth.HasPermission` directly, so the checks are
per-handler and the pipeline below is a description of the npm publish path
only — other ecosystems order these differently or omit some.

```
Request → JWT validation → Permission check → Scope check → Handler
```

- Missing JWT: `401 Unauthorized`
- Valid JWT, wrong permission: `403 Forbidden`
- Valid JWT, scope mismatch: `403 Forbidden`
- Valid JWT, no permissions claim, `AUTH_SESSION_READS_REGISTRY=true`: `200 OK`
- Valid JWT, no permissions claim, `AUTH_SESSION_READS_REGISTRY=false` (default): `403 Forbidden`

**The second row was the finding, not the first.** `POST /-/v1/login` mints with
neither `Permissions` nor `Scopes`, and `HasPermission` treats an absent
permission list as grant-everything. So a dashboard sign-in satisfied publish,
unpublish and read while failing only admin — the inverse of what #244
intended when it moved the admin gates onto an explicit check and left these
three behind. The old line called this "backward compatible with legacy
tokens"; the only credential that reaches that branch in practice is a login
session, so it was the gap, described as a feature.

Unpublish and publish-over-existing were already contained by
`checkPackageOwnership`. **First publish was not** — there is no scope-ownership
model to check against, so any authenticated user could create a package in a
scope they did not own and take ownership of it. Issue #330; fixed in
`auth.HasSessionAwarePermission`, with `auth.IsSession` as the single place the
distinction is made.

Permission types: `read`, `publish`, `unpublish`, `admin`.

---

## 17. Account Linking Security

When a user logs in via OAuth, the registry links the OAuth identity to a Packr account by **verified email address**:

- Only provider-verified emails are used for linking (GitHub: only primary verified email; Google: always verified; GitLab: only verified primary email).
- If no existing Packr account has the email, a new account is created automatically.
- If a Packr account exists with that email, the OAuth identity is linked to it. The user can then log in via password or OAuth interchangeably.
- A single Packr account can have multiple linked OAuth identities (e.g., GitHub + Google with the same email).
- Admins can unlink OAuth identities via the dashboard.

---

## 18. Security Audit History

| Date | Scope | Findings | Status |
|---|---|---|---|
| April 2026 | Comprehensive audit of registry, dashboard, and infrastructure | 24 findings: 4 CRITICAL, 6 HIGH, 8 MEDIUM, 6 LOW | All remediated |

The April 2026 audit covered authentication flows, authorization enforcement, input validation, rate limiting, webhook security, session management, CORS configuration, resource limits, infrastructure hardening, OAuth device flow, and token permission enforcement. All findings have been addressed and verified.
