Skip to content

Conversation

@gocanto
Copy link
Collaborator

@gocanto gocanto commented Aug 8, 2025

Summary by CodeRabbit

  • New Features

    • Adds HMAC token authentication with replay protection, timestamp validation, nonce replay cache, and failure-based rate limiting.
    • Attaches request IDs and authenticated identity to requests for tracing.
  • New Components

    • In-memory TTL key cache and in-memory failure-based rate limiter.
    • Utilities for canonical request building, hashing, body-size-limited reads, and client IP extraction.
  • Tests

    • Extensive unit and integration tests for middleware, cache, limiter, and utilities.
  • Chores

    • CI workflow conditions refined; CORS expanded to allow new auth headers.
  • Documentation

    • Postman collections and comprehensive token middleware docs and examples added.

@coderabbitai
Copy link

coderabbitai bot commented Aug 8, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Caution

Review failed

The pull request is closed.

Walkthrough

Replaces many imports from github.com/oullin/pkg with github.com/oullin/pkg/portal, adds a new HMAC token middleware with nonce replay protection and rate limiting, introduces TTL cache and memory limiter utilities, expands CORS/forwarded headers, updates workflow label conditions, and adds tests and docs.

Changes

Cohort / File(s) Change Summary
Workflow Condition Updates
.github/workflows/gofmt.yml, .github/workflows/tests.yml
Added label-based run conditions: gofmt runs only on PR labeled events with label style; tests job now checks whether the testing label exists among PR labels.
Portal migration (imports & types)
(multiple files)
database/.../posts_filters.go, database/seeder/*.go, handler/*.go, main.go, metal/cli/*.go, metal/kernel/*.go, metal/cli/posts/*.go, metal/.../kernel_test.go, pkg/markdown/schema.go, ...
Replaced imports from github.com/oullin/pkg to github.com/oullin/pkg/portal and updated corresponding type/function references and a few signatures/fields to portal types.
Portal subpackage & client changes
pkg/portal/*.go, pkg/portal/*_test.go, pkg/portal/client.go
Converted package to portal, refactored default validator singleton placement, and replaced direct io.ReadAll usage with portal.ReadWithSizeLimit for HTTP body reads.
Portal utilities
pkg/portal/support.go, pkg/portal/support_test.go
Added utility helpers: CloseWithLog, Sha256Hex, SortedQuery, BuildCanonical, ParseClientIP, ReadWithSizeLimit and comprehensive tests.
TTL Cache
pkg/cache/ttl_cache.go, pkg/cache/ttl_cache_test.go
New in-memory TTL-only cache (Used/Mark/UseOnce) with thread-safety and unit test.
In-memory Rate Limiter
pkg/limiter/limiter.go, pkg/limiter/limiter_test.go
New MemoryLimiter tracking recent failure timestamps with TooMany/Fail and unit test.
Old Token Middleware Removal
pkg/http/middleware/token_middleware.go, pkg/http/middleware/token_middleware_test.go, pkg/support.go, pkg/support_test.go
Removed legacy token middleware and its tests, and removed the old pkg support helper/tests.
New Token Middleware & Timestamp Validator
pkg/middleware/token_middleware.go, pkg/middleware/token_middleware_test.go, pkg/middleware/valid_timestamp.go, pkg/middleware/valid_timestamp_test.go
Added TokenCheckMiddleware implementing header validation, HMAC signature verification, body hashing, nonce replay protection (TTL cache), failure-based rate limiting (MemoryLimiter), skewed timestamp validation, context propagation, and extensive unit/integration tests.
Markdown fetch safety
pkg/markdown/handler.go
Replaced io.ReadAll with portal.ReadWithSizeLimit and added an HTTP client timeout for remote markdown fetches.
CORS / Reverse Proxy Headers
caddy/Caddyfile.local, caddy/Caddyfile.prod
Added X-API-Timestamp, X-API-Nonce, X-Request-ID to Access-Control-Allow-Headers/Expose-Headers and to forwarded headers.
Docs & Examples
docs/middleware/*.md, docs/middleware/postman/*.json
Added token middleware analyses (v1/v2), examples for Postman/Node/Browser, and Postman collections for local/prod testing.
Tests & Misc
pkg/cache/*, pkg/limiter/*, pkg/middleware/*_test.go, other updated tests
New and updated tests covering TTL cache, limiter, valid timestamp, token middleware integration, and portal support helpers.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Middleware
  participant RateLimiter
  participant NonceCache
  participant ApiKeysRepo
  participant TokenHandler
  participant Handler

  Client->>Middleware: HTTP request + headers (username,key,signature,ts,nonce,req-id) + body
  Middleware->>RateLimiter: TooMany(account|ip)?
  RateLimiter-->>Middleware: allowed/limited
  Middleware->>NonceCache: Too recently used?
  NonceCache-->>Middleware: used/unused
  Middleware->>ApiKeysRepo: Load API keys for account
  ApiKeysRepo-->>Middleware: api key record
  Middleware->>TokenHandler: Decode token / validate
  TokenHandler-->>Middleware: token data / error
  Middleware->>Middleware: Verify signature, timestamp, body hash
  alt All checks pass
    Middleware->>NonceCache: Mark(nonce)
    Middleware->>Handler: call next with context (account, req-id)
  else Any check fails
    Middleware->>RateLimiter: Fail(account|ip)
    Middleware-->>Client: 401 Unauthorized (or 429 if limited)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

Suggested labels

testing

Poem

"I nibble code beneath the moon,
Moved packages, made a nonce balloon.
Signatures snug, timestamps in tune,
Nonces expire, rate limits swoon.
Hop — safe requests arrive at noon!"


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5049a4f and 29923d6.

📒 Files selected for processing (2)
  • pkg/middleware/token_middleware.go (1 hunks)
  • pkg/middleware/valid_timestamp.go (1 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/better-token

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

gemini-code-assist[bot]

This comment was marked as resolved.

gemini-code-assist[bot]

This comment was marked as resolved.

@gocanto gocanto added the testing label Aug 8, 2025
@gocanto gocanto added testing and removed testing labels Aug 8, 2025
@gocanto gocanto force-pushed the chore/better-token branch from cd23192 to 8ae6ed6 Compare August 8, 2025 05:42
@gocanto gocanto added testing and removed testing labels Aug 8, 2025
@gocanto gocanto removed the testing label Aug 8, 2025
@gocanto gocanto added testing and removed testing labels Aug 8, 2025
@gocanto gocanto force-pushed the chore/better-token branch from 1ffec63 to 72c3101 Compare August 8, 2025 06:50
@gocanto gocanto added testing and removed testing labels Aug 8, 2025
@gocanto gocanto added testing and removed testing labels Aug 8, 2025
@gocanto gocanto removed the style label Aug 8, 2025
@gocanto gocanto marked this pull request as ready for review August 8, 2025 09:50
@gocanto gocanto added the style label Aug 8, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (9)
pkg/cache/ttl_cache.go (1)

51-55: Mark does a full-map sweep on every call – could hurt latency under load

for k, exp := range c.data { … } is O(n) each time a key is written.
With high QPS or large nonce sets this becomes a hotspot.

Consider lighter strategies (sampled pruning, background sweeper, or size threshold) to bound per-request cost.

pkg/middleware/token_middleware.go (2)

97-101: Redundant nil-check for logger

slog.With(...) never returns nil; keeping logger == nil complicates the guard without benefit.

- if reqID == "" || logger == nil {
+ if reqID == "" {
     return t.getInvalidRequestError()
 }

257-267: Nonce is marked before signature verification – enables cheap cache-flooding

A request with an invalid signature still consumes a fresh nonce, letting an attacker rapidly bloat nonceCache.

Shift UseOnce after signature verification or roll the mark back on failure so only successful authentications reserve the nonce.

docs/middleware/token_examples.md (1)

50-54: Nit: avoid redefining pm.sendRequest

The line pm.sendRequest = pm.sendRequest; is a no-op, can be dropped.

docs/middleware/token_analysis_v1.md (5)

23-24: Fix status code inconsistency (legacy returned 403, v1 should return 401)

Section 1 says the middleware returns 401, but the legacy code (see provided snippet) returned 403. Keep Section 1 historically accurate and reserve the 401 note for the improvements section.

Apply this diff to Section 1 (legacy behavior):

-7. On success, logs authentication success and calls the next handler; otherwise returns http.ApiError with Status 401 (generic message).
+7. On success, logs authentication success and calls the next handler; otherwise returns http.ApiError with Status 403 (generic message).

And keep the “Quick wins” item about switching to 401 as-is (since v1 does this).

Also applies to: 45-47


225-233: Fix heading levels and trailing punctuation (markdownlint MD001/MD026) in Section 8

Convert sub-section labels into proper H3 headings without trailing colons and keep the increment consistent.

Apply this diff:

-## 8) Example canonical signature spec (for future adoption)
+## 8) Example canonical signature spec (for future adoption)

-Headers required:
+### Headers required
 - X-API-Username
 - X-API-Key
 - X-API-Timestamp (epoch seconds)
 - X-API-Nonce (UUID v4)
 - X-API-Signature

-Canonical request (string to sign):
+### Canonical request (string to sign)

 METHOD + "\n" +
 PATH + "\n" +
 SORTED_QUERY_STRING + "\n" +
 X-API-Username + "\n" +
 X-API-Key + "\n" +
 X-API-Timestamp + "\n" +
 X-API-Nonce + "\n" +
 SHA256_HEX(BODY)

-Signature:
+### Signature
 - signature = hex(HMAC-SHA256(secretKey, canonical_request))

-Validation rules:
+### Validation rules
 - Accept if |now - timestamp| <= 300s, nonce unused within window, and constant-time comparison passes.

Also applies to: 234-244, 245-250


265-278: Address markdownlint issues and improve readability for docker-compose section

Use proper subheadings rather than list items with colons; remove trailing colon from “Volumes:”.

Apply this diff:

-- Containers and networks (docker-compose.yml):
-  - Services:
+### Containers and networks (docker-compose.yml)
+  - Services
     - api: Go API built from docker/dockerfile-api; exposes ENV_HTTP_PORT (default 8080) to the caddy_net and oullin_net networks. DB host is api-db via Docker DNS. Secrets are injected using Docker secrets (pg_username, pg_password, pg_dbname).
     - api-db: Postgres 17.3-alpine. Port bound to 127.0.0.1:${ENV_DB_PORT:-5432} (not exposed publicly). Uses Docker secrets for credentials. Includes healthcheck and SSL files mounted read-only.
     - api-db-migrate: Runs migrations from database/infra/migrations via a wrapper script.
     - api-runner: Convenience container to run Go commands (e.g., seeders) with the code mounted at /app, sharing the network with api-db.
     - caddy_local (profile local): Reverse proxy for local development. Host ports 8080->80 and 8443->443. Caddyfile: caddy/Caddyfile.local.
     - caddy_prod (profile prod): Public reverse proxy/terminates TLS via Let’s Encrypt. Host ports 80/443 exposed. Caddyfile: caddy/Caddyfile.prod.
-  - Networks:
+  - Networks
     - caddy_net: Fronting proxy <-> API network.
     - oullin_net: Internal network for API <-> DB and runner.
-  - Volumes:
+  - Volumes
     - caddy_data, caddy_config, oullin_db_data for persistence; go_mod_cache for cached modules in api-runner.

281-282: Fix bare URLs (markdownlint MD034) by wrapping in angle brackets

Wrap URLs to satisfy MD034 and improve consistency.

Apply this diff:

-  - Listens on :80 in the container (published as http://localhost:8080 on the host).
+  - Listens on :80 in the container (published as <http://localhost:8080> on the host).
@@
-  - API is routed under /api/* and proxied to api:8080. That means production API path = https://oullin.io/api/... while local is http://localhost:8080/....
+  - API is routed under /api/* and proxied to api:8080. That means production API path = <https://oullin.io/api/...> while local is <http://localhost:8080/...>.
@@
-  - In production behind Caddy, the protected routes are under /api (e.g., POST https://oullin.io/api/posts). Locally through caddy_local they are at http://localhost:8080/posts.
+  - In production behind Caddy, the protected routes are under /api (e.g., POST <https://oullin.io/api/posts>). Locally through caddy_local they are at <http://localhost:8080/posts>.

Also applies to: 287-288, 297-301


154-170: Minimize client-visible detail: also avoid leaking normalized account names

This section is good; add that even normalized account names should be omitted in client-facing messages.

Apply this diff:

 - Don’t echo credentials:
-  - Avoid returning account name, token, or signature in error messages. Use generic client-facing errors.
+  - Avoid returning account name (even normalized), token, or signature in error messages. Use generic client-facing errors.
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e61b42 and 4412089.

📒 Files selected for processing (15)
  • database/seeder/seeds/users.go (2 hunks)
  • docs/middleware/postman/token-local.postman_collection.json (1 hunks)
  • docs/middleware/postman/token-prod.postman_collection.json (1 hunks)
  • docs/middleware/token_analysis_v1.md (1 hunks)
  • docs/middleware/token_analysis_v2.md (1 hunks)
  • docs/middleware/token_examples.md (1 hunks)
  • metal/cli/main.go (2 hunks)
  • metal/cli/posts/factory.go (1 hunks)
  • metal/cli/posts/handler_test.go (2 hunks)
  • pkg/cache/ttl_cache.go (1 hunks)
  • pkg/middleware/token_middleware.go (1 hunks)
  • pkg/portal/client.go (1 hunks)
  • pkg/portal/client_test.go (1 hunks)
  • pkg/portal/support.go (1 hunks)
  • pkg/portal/validator.go (2 hunks)
✅ Files skipped from review due to trivial changes (6)
  • pkg/portal/client_test.go
  • pkg/portal/client.go
  • metal/cli/main.go
  • metal/cli/posts/handler_test.go
  • metal/cli/posts/factory.go
  • docs/middleware/postman/token-local.postman_collection.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/portal/validator.go
  • database/seeder/seeds/users.go
  • pkg/portal/support.go
🧰 Additional context used
🧬 Code Graph Analysis (2)
pkg/middleware/token_middleware.go (3)
pkg/portal/support.go (3)
  • BuildCanonical (54-74)
  • ParseClientIP (76-91)
  • Sha256Hex (25-28)
pkg/http/middleware/token_middleware.go (4)
  • Handle (30-53)
  • shallReject (55-83)
  • getUnauthenticatedError (106-118)
  • getInvalidRequestError (85-97)
pkg/auth/handler_test.go (1)
  • TestTokenHandlerLifecycle (5-33)
docs/middleware/token_analysis_v1.md (3)
pkg/http/middleware/token_middleware.go (4)
  • Handle (30-53)
  • shallReject (55-83)
  • MakeTokenMiddleware (23-28)
  • getUnauthenticatedError (106-118)
pkg/http/middleware/token_middleware_test.go (2)
  • TestTokenMiddlewareErrors (12-32)
  • TestTokenMiddlewareHandleInvalid (34-48)
pkg/auth/handler_test.go (1)
  • TestTokenHandlerLifecycle (5-33)
🪛 markdownlint-cli2 (0.17.2)
docs/middleware/token_analysis_v1.md

13-13: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)


20-20: Bare URL used

(MD034, no-bare-urls)


21-21: Bare URL used

(MD034, no-bare-urls)


46-46: Bare URL used

(MD034, no-bare-urls)


46-46: Bare URL used

(MD034, no-bare-urls)


107-107: Bare URL used

(MD034, no-bare-urls)


277-277: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)


278-278: Bare URL used

(MD034, no-bare-urls)

🔇 Additional comments (4)
pkg/cache/ttl_cache.go (1)

60-75: Good atomic check-and-set for replay protection

UseOnce closes the race window called out in the earlier review – nice fix.

pkg/middleware/token_middleware.go (1)

245-255: Correct constant-time comparison – looks good

Hashing both inputs to fixed length before subtle.ConstantTimeCompare eliminates the length-leak side-channel. Nice catch.

docs/middleware/postman/token-prod.postman_collection.json (1)

70-75: Ensure secrets are stored only in environment variables

Just a reminder: keep secretKey Postman env values out of version control.

docs/middleware/token_analysis_v2.md (1)

118-126: Great roadmap – nothing to add

Documentation is thorough and aligns with the codebase direction.

@gocanto gocanto marked this pull request as draft August 11, 2025 03:13
@gocanto gocanto removed the style label Aug 11, 2025
@gocanto gocanto marked this pull request as ready for review August 11, 2025 03:18
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (9)
pkg/portal/support.go (3)

30-52: Deterministic query building is solid; consider clarifying comment

You sort keys and values for stable canonicalization (Values.Encode only sorts keys). Consider a short code comment to document “value sorting ensures deterministic ordering for multi-value keys”.

Suggested inline note:

-   vals := q[k]
+   // Sort values to ensure deterministic ordering for multi-value keys
+   vals := q[k]

54-74: Path fallback can be more robust with EscapedPath

For URLs where RawPath is set but Path is empty, the current condition defaults to “/”. Prefer using EscapedPath first, then fallback.

-func BuildCanonical(method string, u *url.URL, username, public, ts, nonce, bodyHash string) string {
-    path := "/"
-
-    if u != nil && u.Path != "" {
-        path = u.EscapedPath()
-    }
+func BuildCanonical(method string, u *url.URL, username, public, ts, nonce, bodyHash string) string {
+    path := "/"
+    if u != nil {
+        path = u.EscapedPath()
+        if path == "" {
+            path = "/"
+        }
+    }

76-91: X-Forwarded-For trust and IP validation

Parsing the first XFF entry is common, but:

  • Only trust XFF if requests come from trusted proxies/load balancers.
  • Consider validating the extracted IP via net.ParseIP; if invalid, fall back to RemoteAddr.

Minimal adjustment:

-    parts := strings.Split(xff, ",")
-    return strings.TrimSpace(parts[0])
+    parts := strings.Split(xff, ",")
+    candidate := strings.TrimSpace(parts[0])
+    if ip := net.ParseIP(candidate); ip != nil {
+        return candidate
+    }
+    // fallthrough to RemoteAddr parsing below

If you want, I can add a helper with a “trustProxy bool” parameter and update call sites.

caddy/Caddyfile.local (2)

16-21: Add Vary to ensure correct caching of CORS responses

Without Vary, proxies/CDNs may cache headers across origins. Recommend adding Vary.

Apply:

 header {
   Access-Control-Allow-Origin "http://localhost:5173" # allows the Vue app (running on localhost:5173) to make requests.
   Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" # Specifies which methods are allowed.
   Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match" # allows the custom headers needed by the API.
   Access-Control-Expose-Headers "ETag, X-Request-ID"
+  Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
 }

24-35: Restrict preflight origin matcher to the allowed origin (avoid reflecting any origin)

You reflect Origin for any preflight request, while the main header allows only http://localhost:5173. Tighten the matcher to prevent mixed signals and unnecessary 204s for disallowed origins.

Apply:

 @preflight {
   method OPTIONS
-  header Origin *
+  header Origin http://localhost:5173
 }

 handle @preflight {
   # Reflect the Origin back so it's always allowed
   header Access-Control-Allow-Origin "{http.request.header.Origin}"
   header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
   header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
   header Access-Control-Max-Age "86400"
+  header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
   respond 204
 }
caddy/Caddyfile.prod (2)

34-39: Add Vary to guard against incorrect CORS caching in intermediaries

Add Vary to ensure correctness under caching/CDNs.

Apply:

   header {
     Access-Control-Allow-Origin "https://oullin.io"
     Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
     Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
     Access-Control-Expose-Headers "ETag, X-Request-ID"
+    Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
   }

41-53: Preflight: restrict origin matcher to your allowed origin

Mirror the allow-list to the preflight matcher for consistency and a smaller attack surface.

Apply:

   @preflight {
     method OPTIONS
-    header Origin *
+    header Origin https://oullin.io
   }

   handle @preflight {
     # Reflect the Origin back so it's always allowed
     header Access-Control-Allow-Origin "{http.request.header.Origin}"
     header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
     header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
     header Access-Control-Max-Age "86400"
+    header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
     respond 204
   }
docs/middleware/token_analysis_v1.md (2)

289-304: Fix markdownlint MD034 (no bare URLs) by wrapping URLs in angle brackets

Wrap raw URLs to satisfy markdownlint.

Apply:

-  - Listens on :80 in the container (published as http://localhost:8080 on the host).
+  - Listens on :80 in the container (published as <http://localhost:8080> on the host).
-  - CORS configured for https://oullin.io within the /api handler. For preflight, echoes Access-Control-Allow-Origin back.
+  - CORS configured for <https://oullin.io> within the /api handler. For preflight, echoes Access-Control-Allow-Origin back.
-  - API is routed under /api/* and proxied to api:8080. That means production API path = https://oullin.io/api/... while local is http://localhost:8080/....
+  - API is routed under /api/* and proxied to api:8080. That means production API path = <https://oullin.io/api/...> while local is <http://localhost:8080/...>.
-  - build-local (build.mk): docker compose --profile local up --build -d (starts api, api-db, caddy_local). After this, the API is reachable at http://localhost:8080.
+  - build-local (build.mk): docker compose --profile local up --build -d (starts api, api-db, caddy_local). After this, the API is reachable at <http://localhost:8080>.

309-313: Fix remaining bare URLs (examples section) for markdownlint

Wrap example URLs.

Apply:

-  - In production behind Caddy, the protected routes are under /api (e.g., POST https://oullin.io/api/posts). Locally through caddy_local they are at http://localhost:8080/posts.
+  - In production behind Caddy, the protected routes are under /api (e.g., POST <https://oullin.io/api/posts>). Locally through caddy_local they are at <http://localhost:8080/posts>.
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4412089 and 9121f8a.

📒 Files selected for processing (8)
  • caddy/Caddyfile.local (2 hunks)
  • caddy/Caddyfile.prod (3 hunks)
  • docs/middleware/token_analysis_v1.md (1 hunks)
  • main.go (2 hunks)
  • pkg/markdown/handler.go (2 hunks)
  • pkg/middleware/token_middleware.go (1 hunks)
  • pkg/portal/client.go (2 hunks)
  • pkg/portal/support.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/portal/client.go
  • pkg/middleware/token_middleware.go
  • main.go
🧰 Additional context used
🧬 Code Graph Analysis (3)
pkg/markdown/handler.go (2)
pkg/portal/support.go (1)
  • ReadWithSizeLimit (96-108)
pkg/markdown/handler_test.go (1)
  • TestParserFetch (9-22)
pkg/portal/support.go (1)
pkg/support_test.go (2)
  • closed (8-11)
  • TestCloseWithLog (19-35)
docs/middleware/token_analysis_v1.md (2)
pkg/http/middleware/token_middleware.go (1)
  • Handle (30-53)
pkg/http/middleware/token_middleware_test.go (1)
  • TestTokenMiddlewareErrors (12-32)
🪛 markdownlint-cli2 (0.17.2)
docs/middleware/token_analysis_v1.md

291-291: Bare URL used

(MD034, no-bare-urls)


292-292: Bare URL used

(MD034, no-bare-urls)


298-298: Bare URL used

(MD034, no-bare-urls)


298-298: Bare URL used

(MD034, no-bare-urls)


299-299: Bare URL used

(MD034, no-bare-urls)


304-304: Bare URL used

(MD034, no-bare-urls)


312-312: Bare URL used

(MD034, no-bare-urls)


312-312: Bare URL used

(MD034, no-bare-urls)

🔇 Additional comments (7)
pkg/markdown/handler.go (1)

8-10: Import migration to portal looks correct

Adopts shared helpers under github.com/oullin/pkg/portal. No issues.

pkg/portal/support.go (1)

15-23: Nil-safety for CloseWithLog is now handled

Guarding nil closers prevents panics. Looks good.

caddy/Caddyfile.local (1)

19-21: CORS headers updated correctly for new auth fields

Allow/Expose lists include X-API-Timestamp, X-API-Nonce, X-Request-ID. Looks good.

caddy/Caddyfile.prod (2)

37-39: CORS headers correctly include new auth headers and X-Request-ID exposure

Allow/Expose sets look consistent with middleware requirements.


60-62: Header forwarding for new auth headers is correct

X-API-Timestamp, X-API-Nonce, and X-Request-ID are forwarded upstream as required.

docs/middleware/token_analysis_v1.md (2)

3-11: Doc scope and file paths are now clearly split between legacy and v1

Legacy vs current locations are explicit. Good fix.


107-114: Canonicalization rules documented clearly

Deterministic rules should prevent signature drift. Nicely done.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
pkg/middleware/token_middleware.go (2)

195-210: Good: bounded body read and body restoration

ReadWithSizeLimit addresses prior DoS risk; body is restored for downstream handlers.


249-256: Good: constant-time compare without length leak

Hashing both inputs before subtle.ConstantTimeCompare fixes the early-exit length side-channel.

🧹 Nitpick comments (14)
pkg/portal/support_test.go (6)

11-27: Add tests for special characters and percent-encoding in query parameters

To harden the contract of SortedQuery, also cover:

  • Keys/values requiring escaping (space, +, %, /, unicode).
  • Idempotence when the query is already sorted.

Example additions:

t.Run("escapes special chars", func(t *testing.T) {
	u, _ := url.Parse("https://x.test/api?sp= a+b&path=a/b&perc=%25&uni=é")
	got := SortedQuery(u)
	// url.QueryEscape encodes as: " " -> "+", "+" -> "%2B", "/" -> "%2F", "%" -> "%25"
	expected := "path=a%2Fb&perc=%2525&sp=+a%2Bb&uni=%C3%A9"
	if got != expected {
		t.Fatalf("expected %q, got %q", expected, got)
	}
})

t.Run("idempotent on already-sorted query", func(t *testing.T) {
	u, _ := url.Parse("https://x.test/api?a=0&a=1&b=2")
	if got := SortedQuery(u); got != "a=0&a=1&b=2" {
		t.Fatalf("unexpected: %q", got)
	}
})

38-43: Add coverage for path escaping and empty path edge cases

BuildCanonical uses u.EscapedPath() and defaults to "/". Add tests for:

  • URL with RawPath (percent-encoded path) to ensure EscapedPath is preserved.
  • Empty path (e.g., u := &url.URL{}) producing "/".

Example:

t.Run("preserves escaped path", func(t *testing.T) {
	u := &url.URL{Path: "/a b", RawPath: "/a%20b"}
	got := BuildCanonical("get", u, "u", "p", "1", "n", "h")
	want := "GET\n/a%20b\n\nu\np\n1\nn\nh"
	if got != want {
		t.Fatalf("expected %q, got %q", want, got)
	}
})

t.Run("empty path becomes slash", func(t *testing.T) {
	u := &url.URL{}
	got := BuildCanonical("get", u, "u", "p", "1", "n", "h")
	want := "GET\n/\n\nu\np\n1\nn\nh"
	if got != want {
		t.Fatalf("expected %q, got %q", want, got)
	}
})

53-55: Prefer errors.Is for error comparisons

Use errors.Is to remain robust if the implementation wraps io.ErrUnexpectedEOF.

Apply this diff:

- if err != io.ErrUnexpectedEOF {
-   t.Errorf("expected io.ErrUnexpectedEOF for nil reader, got %v", err)
- }
+ if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
+   t.Errorf("expected io.ErrUnexpectedEOF for nil reader, got %v", err)
+ }

58-76: Tighten the comment to reflect the next custom-limit test

The note about testing the 5MB default is already addressed by the following custom-limit test. Consider removing or rewording to avoid redundancy.

Minimal cleanup:

- // We can't easily test the default 5MB limit in a unit test,
- // but we can test the logic by using a smaller custom limit
+ // Exceed-path is covered in the following custom-limit test.

112-127: Use errors.Is instead of substring matching for wrapped error

The implementation wraps the underlying read error with %w, so errors.Is is a better fit than string contains.

Apply this diff:

- if err == nil || !strings.Contains(err.Error(), expectedErr.Error()) {
-   t.Errorf("expected error containing %q, got %v", expectedErr, err)
- }
+ if err == nil || !errors.Is(err, expectedErr) {
+   t.Errorf("expected wrapped error %v, got %v", expectedErr, err)
+ }

78-110: Consider adding a test for zero/negative custom limits falling back to default

ReadWithSizeLimit ignores non-positive overrides and uses the default. A quick sanity check would lock this contract.

Add this test:

func TestReadWithSizeLimit_NonPositiveCustomLimitFallsBackToDefault(t *testing.T) {
	// 1MB payload; should be allowed by default 5MB
	payload := strings.Repeat("x", 1<<20)
	for _, override := range []int64{0, -1} {
		t.Run(strings.TrimSpace(strings.ReplaceAll(strings.TrimPrefix(strings.TrimSpace(string(override)), "-"), " ", "")), func(t *testing.T) {
			data, err := ReadWithSizeLimit(strings.NewReader(payload), override)
			if err != nil {
				t.Fatalf("unexpected error with override=%d: %v", override, err)
			}
			if got := len(data); got != len(payload) {
				t.Fatalf("unexpected size: got=%d want=%d", got, len(payload))
			}
		})
	}
}
pkg/middleware/valid_timestamp.go (2)

35-38: Nil logger should not cause 401; fall back to default logger instead

Failing auth when the logger is nil is surprising. Degrade gracefully by using slog.Default() (or a no-op) rather than returning 401.

- if v.logger == nil {
-   return &http.ApiError{Message: "Invalid timestamp headers tracker", Status: baseHttp.StatusUnauthorized}
- }
+ if v.logger == nil {
+   v.logger = slog.Default() // fallback; don't fail auth on missing logger
+ }

36-36: Message wording nit

"Invalid timestamp headers tracker" reads odd. Consider aligning with other generic errors.

- return &http.ApiError{Message: "Invalid timestamp headers tracker", Status: baseHttp.StatusUnauthorized}
+ return &http.ApiError{Message: "Invalid authentication headers", Status: baseHttp.StatusUnauthorized}
docs/middleware/token_analysis_v2.md (2)

64-69: Doc-code mismatch: disallowFuture default is true in code

MakeTokenMiddleware sets disallowFuture = true by default, but docs state false. Update docs to reflect the code.

- - clockSkew: 5m; disallowFuture: false
+ - clockSkew: 5m; disallowFuture: true

60-62: Fix unordered list indentation (MD007)

markdownlint flags the indentation on these bullets. Unindent to the top-level list indentation.

-   - Uses structured slog logger with request_id, method, path.
-   - Logs warnings for missing headers, invalid format, too many failures, account not found, replay detected, signature mismatch.
-   - Errors are generic to clients (HTTP 401 with neutral messages) to avoid leaking details.
+ - Uses structured slog logger with request_id, method, path.
+ - Logs warnings for missing headers, invalid format, too many failures, account not found, replay detected, signature mismatch.
+ - Errors are generic to clients (HTTP 401 with neutral messages) to avoid leaking details.
pkg/middleware/token_middleware.go (3)

168-169: Minor logging nit: add space after comma when joining

For readability, join with ", " instead of ",".

- logger.Error("token middleware missing dependencies", "missing", strings.Join(missing, ","))
+ logger.Error("token middleware missing dependencies", "missing", strings.Join(missing, ", "))

104-106: Redundant nil check for logger

slog.With(...) never returns nil. You can simplify to just check reqID.

- if reqID == "" || logger == nil {
+ if reqID == "" {
   return t.getInvalidRequestError()
 }

84-97: Dead config fields or duplication: failWindow/maxFailPerScope not wired

failWindow and maxFailPerScope are set but not used to configure rateLimiter (hardcoded to 1m/10). Either remove the fields or pass them into the limiter ctor to avoid drift.

- rateLimiter:     limiter.NewMemoryLimiter(1*time.Minute, 10),
- failWindow:      1 * time.Minute,
- maxFailPerScope: 10,
+ failWindow:      1 * time.Minute,
+ maxFailPerScope: 10,
+ rateLimiter:     limiter.NewMemoryLimiter(failWindow, maxFailPerScope),
pkg/middleware/token_middleware_test.go (1)

235-311: Solid end-to-end path; consider adding replay and limiter tests

Great coverage of happy/negative DB paths. Please add:

  • Nonce replay test: reuse same nonce for a signed request and expect 401 on the second try.
  • Rate limiter test: trigger repeated failures until TooMany and assert 429.

I can draft these tests with minimal scaffolding if helpful.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9121f8a and 5049a4f.

📒 Files selected for processing (9)
  • caddy/Caddyfile.prod (3 hunks)
  • docs/middleware/token_analysis_v2.md (1 hunks)
  • pkg/markdown/handler.go (3 hunks)
  • pkg/middleware/token_middleware.go (1 hunks)
  • pkg/middleware/token_middleware_test.go (1 hunks)
  • pkg/middleware/valid_timestamp.go (1 hunks)
  • pkg/middleware/valid_timestamp_test.go (1 hunks)
  • pkg/portal/support.go (1 hunks)
  • pkg/portal/support_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • pkg/middleware/valid_timestamp_test.go
  • pkg/markdown/handler.go
  • caddy/Caddyfile.prod
  • pkg/portal/support.go
🧰 Additional context used
🧬 Code Graph Analysis (4)
pkg/portal/support_test.go (1)
pkg/portal/support.go (3)
  • SortedQuery (31-53)
  • BuildCanonical (55-75)
  • ReadWithSizeLimit (97-118)
pkg/middleware/valid_timestamp.go (1)
pkg/http/schema.go (1)
  • ApiError (10-13)
docs/middleware/token_analysis_v2.md (1)
pkg/http/middleware/token_middleware_test.go (1)
  • TestTokenMiddlewareErrors (12-32)
pkg/middleware/token_middleware_test.go (9)
pkg/http/schema.go (1)
  • ApiError (10-13)
database/connection.go (2)
  • Connection (12-17)
  • MakeConnection (19-32)
metal/env/env.go (1)
  • Environment (9-15)
metal/env/db.go (1)
  • DBEnvironment (5-14)
database/model.go (2)
  • DriverName (9-9)
  • APIKey (26-35)
pkg/auth/encryption.go (1)
  • CreateSignatureFrom (80-85)
pkg/auth/handler.go (1)
  • MakeTokensHandler (16-26)
database/repository/api_keys.go (1)
  • ApiKeys (11-13)
pkg/http/middleware/token_middleware.go (2)
  • Handle (30-53)
  • shallReject (55-83)
🪛 markdownlint-cli2 (0.17.2)
docs/middleware/token_analysis_v2.md

60-60: Unordered list indentation
Expected: 0; Actual: 3

(MD007, ul-indent)


61-61: Unordered list indentation
Expected: 0; Actual: 3

(MD007, ul-indent)


62-62: Unordered list indentation
Expected: 0; Actual: 3

(MD007, ul-indent)

🔇 Additional comments (8)
pkg/portal/support_test.go (4)

11-18: Good coverage of basic sorting behavior in SortedQuery

Inputs exercise multi-value sorting and verify deterministic ordering. Looks correct.


29-37: Canonical builder happy path looks solid

Validates method uppercasing, EscapedPath usage, and query sorting in the canonical string. Looks good.


78-110: Custom limit path is well covered

Verifies both under- and over-limit behavior with a small limit. This is clear and effective.


129-136: Simple mock reader is fine

Minimal, readable, and adequate for exercising error propagation.

pkg/middleware/token_middleware.go (1)

118-123: Good: explicit timestamp validation via helper

Clear separation of concerns with ValidTimestamp and configurable skew/future policy.

pkg/middleware/token_middleware_test.go (3)

93-111: Dependency: slogNoop must exist in this package

These tests call slogNoop(); ensure it’s defined in the same package (e.g., in valid_timestamp_test.go) and exported within the test package scope (not middleware_test vs middleware).


369-378: Good: asserts default disallowFuture true

Matches code defaults in MakeTokenMiddleware.


380-438: Good: rejects future timestamps with disallowFuture=true

Covers the policy well and ensures next is not invoked.

@gocanto gocanto merged commit 2625723 into main Aug 11, 2025
5 of 6 checks passed
@gocanto gocanto deleted the chore/better-token branch August 11, 2025 04:29
@coderabbitai coderabbitai bot mentioned this pull request Sep 23, 2025
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants