Skip to content

fix(router): return 405 with Allow header for method mismatch#71

Open
ojaswa072 wants to merge 3 commits intoOWASP-BLT:mainfrom
ojaswa072:fix/router-405-allow-header
Open

fix(router): return 405 with Allow header for method mismatch#71
ojaswa072 wants to merge 3 commits intoOWASP-BLT:mainfrom
ojaswa072:fix/router-405-allow-header

Conversation

@ojaswa072
Copy link
Contributor

@ojaswa072 ojaswa072 commented Mar 20, 2026

Summary

Fixes router method handling so existing paths return 405 (with Allow) instead of 404, while unknown paths still return 404.

Changes

  • Added method-aware path matching.
  • Added sorted Allow header for 405 responses.
  • Reused single regex match per route.
  • Replaced raw 500 exception text with generic message and server-side logging.
  • Added tests for 405 + Allow (single and multi-method) and 404 fallback.

Testing

  • Ran router tests.
  • Verified 405 + Allow behavior.
  • Verified unknown paths return 404.

@owasp-blt
Copy link

owasp-blt bot commented Mar 20, 2026

📊 Monthly Leaderboard

Hi @ojaswa072! Here's how you rank for March 2026:

Rank User Open PRs PRs (merged) PRs (closed) Reviews Comments Total
🥉 3 Jayant2908 @Jayant2908 4 32 38 25 16 405
4 ojaswa072 @ojaswa072 18 33 1 5 6 383
5 e-esakman @e-esakman 2 27 0 15 5 357

Scoring this month (across OWASP-BLT org): Open PRs (+1 each), Merged PRs (+10), Closed (not merged) (−2), Reviews (+5; first two per PR in-month), Comments (+2, excludes CodeRabbit). Run /leaderboard on any issue or PR to see your rank!

@owasp-blt
Copy link

owasp-blt bot commented Mar 20, 2026

👋 Hi @ojaswa072!

This pull request needs a peer review before it can be merged. Please request a review from a team member who is not:

  • The PR author
  • coderabbitai
  • copilot

Once a valid peer review is submitted, this check will pass automatically. Thank you!

⚠️ Peer review enforcement is active.

@coderabbitai
Copy link

coderabbitai bot commented Mar 20, 2026

Walkthrough

Router dispatch now collects methods from routes whose regex matches the requested path and returns 405 Method Not Allowed with an Allow header when the path matches but the method does not. Handler param extraction uses route.regex.match(path).groupdict() and error formatting uses e!s.

Changes

Cohort / File(s) Summary
Router method dispatch logic
src/router.py
Match routes by compiled regex first, collect allowed route.method values for path matches, return 405 with Allow header when no matching method found; extract handler params via route.regex.match(path).groupdict(); use e!s for 500 error formatting.
Tests for method behavior
tests/test_router.py
Updated imports to load router from src via sys.path mutation, added json import and _MockRequest helper, and introduced TestRouterHandleMethodBehavior async tests verifying 405 responses with sorted Allow headers and 404 behavior.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Router
    participant RouteCollection
    participant Response

    Client->>Router: HTTP request (method, path)
    Router->>RouteCollection: iterate routes (use route.regex to match path)
    loop per route
        RouteCollection->>Router: regex matches? (yes/no)
        alt regex matches
            Router->>Router: collect route.method
        end
    end

    alt path matched AND method matches
        Router->>Response: invoke handler -> 200/handler response
        Response->>Client: handler response
    else path matched BUT method didn't match
        Router->>Response: return 405 with Allow header (sorted methods)
        Response->>Client: 405 Method Not Allowed + Allow
    else no path matched
        Router->>Response: return 404 Not Found
        Response->>Client: 404 Not Found
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

quality: medium

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a routing bug to return 405 with Allow header for method mismatches.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

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

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

🧹 Nitpick comments (2)
src/router.py (1)

163-167: Avoid duplicate regex evaluation in the route loop.

You can compute the path regex match once per route and reuse it for both allowed_methods and handler dispatch.

♻️ Proposed simplification
-        for route in self.routes:
-            if route.regex.match(path):
-                allowed_methods.add(route.method)
-
-            path_params = route.match(method, path)
-            if path_params is not None:
+        for route in self.routes:
+            path_match = route.regex.match(path)
+            if not path_match:
+                continue
+
+            allowed_methods.add(route.method)
+            if route.method == method:
+                path_params = path_match.groupdict()
                 try:
                     return await route.handler(
                         request=request,
                         env=env,
                         path_params=path_params,
                         query_params=query_params,
                         path=path
                     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/router.py` around lines 163 - 167, Compute the regex result once per
route and reuse it: call route.regex.match(path) into a local variable (e.g.,
match) and use that to decide adding to allowed_methods and to gate the handler
dispatch; then only call route.match(method, path) when the stored match
indicates a path match (to avoid re-evaluating the regex). Update the logic
around allowed_methods, the path_params assignment, and any conditionals that
currently call route.regex.match(path) or route.match(method, path) so they use
the single stored match value.
tests/test_router.py (1)

205-223: Add one test for multi-method Allow aggregation.

Consider adding a case where the same path is registered for multiple methods to verify the combined/sorted Allow header contract.

🧪 Suggested test addition
 class TestRouterHandleMethodBehavior:
@@
     async def test_handle_returns_405_with_allow_header_when_path_exists(self):
         router = Router()
@@
         assert body["status"] == 405
         assert body["message"] == "Method Not Allowed"
+
+    `@pytest.mark.asyncio`
+    async def test_handle_returns_405_with_all_allowed_methods_for_path(self):
+        router = Router()
+
+        async def get_handler(request, env, path_params, query_params, path):
+            return {"ok": True}
+
+        async def put_handler(request, env, path_params, query_params, path):
+            return {"ok": True}
+
+        router.add_route("GET", "/bugs", get_handler)
+        router.add_route("PUT", "/bugs", put_handler)
+
+        request = _MockRequest("DELETE", "https://example.com/bugs")
+        response = await router.handle(request, env={})
+
+        assert getattr(response, "status", None) == 405
+        assert response.headers.get("Allow") == "GET, PUT"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_router.py` around lines 205 - 223, Add a new async test mirroring
test_handle_returns_405_with_allow_header_when_path_exists that registers the
same path with multiple methods using Router.add_route (e.g., "GET", "HEAD",
"POST" handlers), then call Router.handle with a different method to trigger 405
and assert the response.headers["Allow"] contains the aggregated, sorted methods
(e.g., "GET, HEAD, POST") and the body retains status 405 and message "Method
Not Allowed"; reference Router, add_route and handle to locate where to add the
test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/router.py`:
- Around line 163-167: Compute the regex result once per route and reuse it:
call route.regex.match(path) into a local variable (e.g., match) and use that to
decide adding to allowed_methods and to gate the handler dispatch; then only
call route.match(method, path) when the stored match indicates a path match (to
avoid re-evaluating the regex). Update the logic around allowed_methods, the
path_params assignment, and any conditionals that currently call
route.regex.match(path) or route.match(method, path) so they use the single
stored match value.

In `@tests/test_router.py`:
- Around line 205-223: Add a new async test mirroring
test_handle_returns_405_with_allow_header_when_path_exists that registers the
same path with multiple methods using Router.add_route (e.g., "GET", "HEAD",
"POST" handlers), then call Router.handle with a different method to trigger 405
and assert the response.headers["Allow"] contains the aggregated, sorted methods
(e.g., "GET, HEAD, POST") and the body retains status 405 and message "Method
Not Allowed"; reference Router, add_route and handle to locate where to add the
test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: OWASP-BLT/coderabbit/.coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: c8d984c8-c51c-4408-b582-314ea2cdd20d

📥 Commits

Reviewing files that changed from the base of the PR and between d75ead9 and 9b3863b.

📒 Files selected for processing (2)
  • src/router.py
  • tests/test_router.py

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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/router.py`:
- Around line 181-184: The except block currently returns raw exception text to
clients; change it to log the full exception server-side (e.g., call
logger.exception or similar within the except Exception as e: handler) and
return a generic 500 error message via error_response (e.g., "Internal server
error" or "An unexpected error occurred") without including e!s; ensure you
still preserve the error for diagnostics by logging the exception and any
relevant context before returning the generic response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: OWASP-BLT/coderabbit/.coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: d39c05dc-9d2a-4525-ab72-7acd94e74936

📥 Commits

Reviewing files that changed from the base of the PR and between 9b3863b and 7289796.

📒 Files selected for processing (2)
  • src/router.py
  • tests/test_router.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_router.py

@azizrebhi
Copy link
Contributor

@coderabbitai analyze this PR thoroughly and check its merge readiness

@coderabbitai
Copy link

coderabbitai bot commented Mar 20, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me gather the actual code to analyze this PR thoroughly.


🏁 Script executed:

#!/bin/bash
echo "=== src/router.py ==="
cat src/router.py

echo ""
echo "=== tests/test_router.py ==="
cat tests/test_router.py

echo ""
echo "=== Git diff ==="
git diff origin/main...HEAD

echo ""
echo "=== Full repo structure ==="
tree -L 3 2>/dev/null || find . -maxdepth 3 -not -path './.git/*' | sort

Length of output: 25965


Good, I have the main files. Let me check a few more critical pieces.


🏁 Script executed:

#!/bin/bash
echo "=== src/utils.py ==="
cat src/utils.py

echo ""
echo "=== src/main.py ==="
cat src/main.py

echo ""
echo "=== tests/conftest.py ==="
cat tests/conftest.py

echo ""
echo "=== pyproject.toml ==="
cat pyproject.toml

Length of output: 14167


@azizrebhi Here is a thorough analysis of PR #71:


✅ Overall Verdict: Ready to merge — with one minor fix recommended

The core logic is correct, RFC-compliant, and well-tested. There is one minor issue worth fixing before merge, and a few low-priority nits.


🔍 Logic & Correctness

src/router.py — Dispatch loop redesign

The new two-phase approach is sound:

for route in self.routes:
    match = route.regex.match(path)
    if not match:
        continue
    allowed_methods.add(route.method)   # collect for Allow header
    if route.method != method:
        continue
    # execute handler ...

if allowed_methods:   # path matched, method didn't
    return 405 + Allow header
return 404            # nothing matched at all

✅ Correct first-match-wins semantics are preserved.
allowed_methods is populated by path-only matching (correct for building the Allow header).
✅ Handler exceptions are no longer leaking raw str(e) details to clients — they now log via logger.exception() and return a generic "Internal Server Error" message. This is a security improvement.
✅ RFC 7231 §6.5.5 compliance: a 405 Method Not Allowed response MUST include an Allow header — correctly implemented.


🐛 Issues Found

1. ⚠️ Unused import (recommend fixing before merge)

json_response is still imported in src/router.py but is no longer used after this refactor. router.py now only calls error_response.

# Line 12 in src/router.py
from utils import error_response, json_response  # json_response is unused

Fix:

from utils import error_response

2. 🟡 Redundant sys.path mutation in test file (nit)

tests/conftest.py already inserts src/ into sys.path, so the additional insert in tests/test_router.py is redundant:

# conftest.py already does this:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

# test_router.py repeats the same:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

This is harmless but creates noise and could be removed.


3. 🟡 Route.match() no longer used internally (nit)

Router.handle() now calls route.regex.match(path) directly, bypassing the Route.match(method, path) convenience method. The method still exists, is still tested via TestRoute, and is part of the public API — so it's not a bug — but the internal bypass creates a subtle inconsistency worth noting for future maintainers.


4. 🟡 Missing test: 500 exception path

The new TestRouterHandleMethodBehavior class doesn't include a test for when a matched handler raises an exception (the new logger.exception + generic 500 path). Consider adding:

`@pytest.mark.asyncio`
async def test_handle_returns_500_when_handler_raises(self):
    router = Router()

    async def bad_handler(request, env, path_params, query_params, path):
        raise RuntimeError("db is down")

    router.add_route("GET", "/bugs", bad_handler)
    request = _MockRequest("GET", "https://example.com/bugs")
    response = await router.handle(request, env={})

    assert getattr(response, "status", None) == 500
    body = json.loads(response.body)
    assert body["status"] == 500
    # Ensure raw exception message is NOT leaked
    assert "db is down" not in body["message"]

✅ What's Done Well

Area Status
RFC 7231 §6.5.5 compliance (Allow header on 405)
Sorted Allow header values
404 preserved for truly unknown paths
Exception details no longer leaked to client
Structured logging with logger.exception()
error_response(headers=...) signature confirmed supported
asyncio_mode = "auto" in pyproject.toml supports async tests
Mock response .headers.get("Allow") works on plain dict
Test coverage for single-method 405, multi-method 405, and 404

📋 Summary

Fix the unused json_response import (issue #1) and this PR is clean to merge. Issues #2#4 are low-priority and can be addressed in follow-up PRs if desired.

@azizrebhi
Copy link
Contributor

@ojaswa072 Nice work on the RFC-compliant 405 handling! The two-phase matching is clean and the security improvement (not leaking exception details) is solid.
Before merge:

  • Remove unused json_response import from src/router.py line 12 (only error_response is used after this refactor)
    Optional follow-ups:
  • Add test for 500 exception path to verify details aren't leaked
  • Remove redundant sys.path manipulation in test_router.py (already in conftest.py)
    Once the import is cleaned up, LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants