diff --git a/.env.example b/.env.example index 12fd603..fd181c9 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,11 @@ MAX_PASTE_SIZE=10485760 # Secret used to sign paste unlock cookies (generate a long random string) PASTE_AUTH_SECRET=change-me-to-a-long-random-string + +# Rate limits (in-memory, per process; suitable for single-node Docker) +# Unlock attempts per paste+client within the window +UNLOCK_RATE_LIMIT=10 +UNLOCK_RATE_WINDOW_MS=600000 +# Create paste attempts per client within the window +CREATE_RATE_LIMIT=60 +CREATE_RATE_WINDOW_MS=600000 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..faccdb8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,47 @@ +name: Bug report +description: Report a defect in PaperCut +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. **Do not** report security vulnerabilities here — use [SECURITY.md](https://github.com/ArianAr/PaperCut/blob/main/SECURITY.md). + - type: input + id: version + attributes: + label: Version + description: Git tag or package.json version (e.g. v0.2.1) + placeholder: v0.2.1 + validations: + required: true + - type: dropdown + id: deploy + attributes: + label: How do you run PaperCut? + options: + - Docker / docker compose + - pnpm dev / pnpm start + - Other + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. … + 2. … + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ae0facc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/ArianAr/PaperCut/security/advisories/new + about: Report security issues privately (do not open a public issue). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6f5963a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Suggest an enhancement for PaperCut +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c3a3d50 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## Summary + + + +## Test plan + +- [ ] `pnpm test` +- [ ] `pnpm lint` / `pnpm typecheck` (or wait for CI) +- [ ] Manual check if UI/API behavior changed + +## Notes + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e83cf99 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# Keep GitHub-native Dependabot config in-repo (reproducible alongside UI settings). +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + labels: + - dependencies + groups: + production-minor: + dependency-type: production + update-types: + - minor + - patch + development: + dependency-type: development + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + labels: + - dependencies + - ci diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6ab71..939b4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GitHub Actions CI (`test`, `typecheck`, `lint`, `build`, aggregate `ci`) on PRs and `main` - Branch ruleset requiring the `ci` check before merging to `main` +- In-memory rate limits for paste create and password unlock (429 + `Retry-After`) +- Security headers (CSP, frame deny, nosniff, referrer policy) via Next config +- `GET /api/health` readiness probe; Docker healthcheck uses it +- Batch purge of expired pastes (`purgeExpiredPastes`) +- `robots.txt` disallows crawlers on `/paste/` and `/api/` +- Repo Dependabot config and issue/PR templates (CodeQL via GitHub default setup) +- CLI `--version` / `-V` + +### Security + +- Bump `drizzle-orm` to ≥0.45.2 (SQL identifier injection advisory) +- Bump `postcss` to ≥8.5.10 (stringify XSS advisory) ### Planned -- Unlock rate limiting for private pastes +- Multi-instance rate limiting (Redis) for horizontally scaled deploys ## [0.2.1] - 2026-07-15 diff --git a/Dockerfile b/Dockerfile index 12bd5ae..f4db594 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,6 @@ EXPOSE 3000 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(async r=>{const j=await r.json().catch(()=>({}));process.exit(r.ok&&j.ok?0:1)}).catch(()=>process.exit(1))" CMD ["node", "server/server.js"] diff --git a/README.md b/README.md index 7b03cdf..2eddefb 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,27 @@ See [`.env.example`](./.env.example). | `DATABASE_PATH` | SQLite file path | `./data/papercut.db` | | `MAX_PASTE_SIZE` | Max body size (bytes) | `10485760` (10 MiB) | | `PASTE_AUTH_SECRET` | HMAC secret for unlock cookies | required in production | +| `UNLOCK_RATE_LIMIT` | Max unlock attempts per paste+client / window | `10` | +| `UNLOCK_RATE_WINDOW_MS` | Unlock rate-limit window (ms) | `600000` (10m) | +| `CREATE_RATE_LIMIT` | Max paste creates per client / window | `60` | +| `CREATE_RATE_WINDOW_MS` | Create rate-limit window (ms) | `600000` (10m) | + +## HTTP API (stable for 1.x) + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/pastes` | Create paste — JSON `{ content, expire?, password? }` → `{ id, url, expiresAt, metadata }` | +| `GET` | `/api/pastes/:id` | Fetch paste (401 + `{ locked: true }` if password required) | +| `POST` | `/api/pastes/:id/unlock` | Unlock — JSON `{ password }` sets httpOnly cookie | +| `GET` | `/api/health` | Liveness/readiness (also purges expired rows) | + +Paste UI: `/paste/:id` (password gate when protected). Line deep links: `#L12`, `#L12-L20`. ## Privacy - PaperCut application code does **not** include analytics SDKs. - Paste bodies and client IPs are **not** intentionally logged by the app. +- Rate limiting may use `X-Forwarded-For` **in memory only** (never written to logs). - Public pastes are **capability URLs** (anyone with the link can read them). Use `--private` for sensitive content. - You control the host when self-hosting. diff --git a/cli/bin.js b/cli/bin.js index 5267b8f..87f713f 100755 --- a/cli/bin.js +++ b/cli/bin.js @@ -12,8 +12,10 @@ const { stdin, stdout, stderr, exit, env, argv, platform } = require("node:proce const DEFAULT_URL = "http://localhost:3000"; +const CLI_VERSION = require("./package.json").version; + function printHelp() { - stdout.write(`papercut — pipe terminal output to an interactive log canvas + stdout.write(`papercut ${CLI_VERSION} — pipe terminal output to an interactive log canvas Usage: | papercut [options] @@ -23,6 +25,7 @@ Options: -p, --private Password-protect the paste --expire