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-suppliedFly-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-Forfallback does not rescue it: it takesparts[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-Forwould 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
HttpOnlyand a 7-dayMaxAge. The other two flags are not uniform, and an earlier revision of this document said they were:Cookie sameSitesecureapi/auth/token/route.ts:13laxNODE_ENV === 'production'api/auth/callback/forgeauth/route.ts:35laxalways trueapi/auth/callback/route.ts:66strictNODE_ENV === 'production'api/auth/device/callback/route.ts:54strictNODE_ENV === 'production'The two
laxcookies 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 buildsecureis off on three of the four β the site is only safe on HTTPS because production setsNODE_ENV. -
Sessions expire after 7 days.
-
Session creation requires the
INTERNAL_API_SECRETshared 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:
ReadTimeout30s,WriteTimeout120s,IdleTimeout60s. ReadHeaderTimeoutis set to 10s;MaxHeaderBytesis 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) β 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.