From bcad7c40b7574f52047f48624d313109064d79bf Mon Sep 17 00:00:00 2001 From: ArianAr Date: Wed, 15 Jul 2026 20:21:02 +0300 Subject: [PATCH 1/4] feat: V1 production hardening, health API, and repo hygiene Add in-memory rate limits for create/unlock, security headers, expired paste purge, /api/health (Docker healthcheck), robots.txt, Dependabot and CodeQL workflow files, issue/PR templates, CLI --version, and API docs. --- .env.example | 8 ++ .github/ISSUE_TEMPLATE/bug_report.yml | 47 ++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 23 ++++ .github/PULL_REQUEST_TEMPLATE.md | 13 +++ .github/dependabot.yml | 28 +++++ .github/workflows/codeql.yml | 47 ++++++++ CHANGELOG.md | 9 +- Dockerfile | 2 +- README.md | 16 +++ cli/bin.js | 17 ++- cli/test/parse-args.test.js | 4 +- server/.env.example | 4 + server/app/api/health/route.ts | 40 +++++++ server/app/api/pastes/[id]/unlock/route.ts | 17 +++ server/app/api/pastes/route.ts | 23 +++- server/lib/cleanup.test.ts | 68 ++++++++++++ server/lib/cleanup.ts | 18 +++ server/lib/rate-limit.test.ts | 58 ++++++++++ server/lib/rate-limit.ts | 123 +++++++++++++++++++++ server/next.config.ts | 37 +++++++ server/public/robots.txt | 5 + 22 files changed, 605 insertions(+), 7 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 server/app/api/health/route.ts create mode 100644 server/lib/cleanup.test.ts create mode 100644 server/lib/cleanup.ts create mode 100644 server/lib/rate-limit.test.ts create mode 100644 server/lib/rate-limit.ts create mode 100644 server/public/robots.txt 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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..567f503 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +# CodeQL analysis (complements any UI-enabled CodeQL setup). +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly Sunday 06:00 UTC + - cron: "0 6 * * 0" + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6ab71..d9e0dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,17 @@ 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, CodeQL workflow, issue/PR templates +- CLI `--version` / `-V` ### 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