Skip to content

Public CORS decorator (PP-5045) - #3680

Merged
tdilauro merged 12 commits into
mainfrom
feature/public-cors-decorator
Sep 1, 2026
Merged

Public CORS decorator (PP-5045)#3680
tdilauro merged 12 commits into
mainfrom
feature/public-cors-decorator

Conversation

@tdilauro

@tdilauro tdilauro commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an allows_public_cors route decorator that sends open CORS headers and handles preflight requests:

  • Access-Control-Allow-Origin: *,
  • no credentials, and
  • preflight advertises only GET, HEAD, and OPTIONS methods.

Stacking it with allows_patron_web in either order, or applying it twice, raises PalaceValueError at decoration time (allows_patron_web gained the reciprocal check). Registering a decorated route that allows write methods also raises PalaceValueError, enforced in PalaceFlask.add_url_rule at route registration time. The decorator's docstring additionally requires placing it outside decorators that can answer a request without calling the view, such as has_library.

An after_request hook backfills the public CORS headers on responses the decorator never sees, applying the same shared options as the decorator through flask-cors's get_cors_options/set_cors_headers, such as a raising view converted by the app-level error handler, or an outer decorator returning a problem detail. Without it, cross-origin clients would get an opaque CORS failure instead of a readable error body. Responses produced before routing resolves an endpoint (a 405 for a method the route does not define, or a strict_slashes redirect) cannot be attributed to a view and are not backfilled; the follow-up PR should register public routes with strict_slashes=False.

No routes use it yet.

Motivation and Context

Public catalog data is readable by anyone without credentials, so an origin allowlist adds no protection there. What it does do is break browser-based catalog clients (like CPW) when their hosts are not configured, which creates friction for dev instances and new deployments. This decorator is the first step. A follow-up PR will apply it to the public routes.

[Jira PP-5045]

How Has This Been Tested?

  • New tests to cover the new functionality, including preflight, error responses, decorator stacking, and registration-time method enforcement.
  • All tests pass locally and in CI.

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @tdilauro's task in 8m 49s —— View job


Code review

  • Gather context (PR diff, base comparison)
  • Review src/palace/manager/api/routes.py
  • Review src/palace/manager/api/util/flask.py
  • Review tests
  • Post review

Summary

This looks correct and well-tested; both of jonathangreen's suggestions (the PalaceFlask.add_url_rule registration guard and reusing set_cors_headers/get_cors_options with a shared _PUBLIC_CORS_OPTIONS) landed cleanly, and I found no defects worth changing. I traced the behavior against flask-cors 6.0.2 semantics rather than assuming: always_send=True plus send_wildcard=True with origins="*" (normalized to .* by re_fix) means the wildcard is emitted even on requests with no Origin header, and vary_header deliberately skips Vary: Origin for a wildcard, so the "stays cacheable" assertion in test_get_allows_any_origin holds; get_cors_options builds its own dict and serialize_options copies before mutating, so passing the shared frozendict (and its frozenset methods, which flask-cors just joins into a string) is safe and the comment at routes.py:209-211 is accurate. The method-resolution logic in the add_url_rule guard matches Flask's own (options.pop("methods")view_func.methods("GET",)), and every decorator that could be stacked outside this one in routes.py/app_server.py (has_library, requires_auth, allows_auth, returns_problem_detail, raises_problem_detail, cache_control_headers, compressible) uses functools.wraps, so the allows_public_cors marker actually survives to the registered view_func for both the guard and the back-fill hook. The hard-500 path the /public-raises test covers is real: Flask's handle_exception calls finalize_request(..., from_error_handler=True), which still runs process_response and therefore the hook. I did not run the test suite — dependencies are not installed in this environment (no venv, flask_cors not importable), so I verified by reading rather than executing; CI and Codecov are green on this branch.
| Branch

@tdilauro
tdilauro requested a review from a team August 28, 2026 15:38
@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a reusable wildcard CORS decorator for public read-only routes, including preflight handling and error-response back-filling.

  • Rejects incompatible CORS decorator combinations.
  • Enforces GET/HEAD/OPTIONS-only route registration.
  • Adds coverage for successful, preflight, short-circuit, and error responses.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/palace/manager/api/routes.py Introduces the public CORS decorator, stacking guards, shared policy, and error-response back-fill hook.
src/palace/manager/api/util/flask.py Adds the read-only public CORS method set and validates marked routes during registration.
tests/manager/api/test_routes.py Adds functional coverage for public CORS responses, preflights, errors, short circuits, and decorator combinations.
tests/manager/api/util/test_flask.py Verifies route-registration enforcement for marked public CORS views.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Request[Cross-origin request] --> Route{Public CORS route?}
    Route -->|No| Normal[Normal response handling]
    Route -->|Yes| Method{OPTIONS preflight?}
    Method -->|Yes| Preflight[Return GET HEAD OPTIONS CORS policy]
    Method -->|No| View[Run decorated view]
    View --> Response{Wrapper sees response?}
    Response -->|Yes| Headers[Add wildcard origin header]
    Response -->|No: error or short-circuit| Hook[After-request hook back-fills header]
    Headers --> Client[Browser-readable response]
    Hook --> Client
Loading

Reviews (11): Last reviewed commit: "CI AI code review feedback" | Re-trigger Greptile

assert False == routes.app.url_map.merge_slashes


class TestAllowsPublicCors:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Behavior-specific test class

TestAllowsPublicCors organizes these tests around one behavior rather than the module under test, contrary to the repository's module-oriented test-class convention and making related route tests less consistent to locate.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.56%. Comparing base (f93b325) to head (f94b6a1).
⚠️ Report is 11 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3680   +/-   ##
=======================================
  Coverage   93.55%   93.56%           
=======================================
  Files         513      513           
  Lines       46907    46948   +41     
  Branches     6405     6414    +9     
=======================================
+ Hits        43884    43926   +42     
+ Misses       1954     1953    -1     
  Partials     1069     1069           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tdilauro

Copy link
Copy Markdown
Contributor Author

More Claude / greptile beef. And I'm here for it! 😂

Note on the earlier Greptile comment
I'd push back on the TestAllowsPublicCors naming complaint. CLAUDE.md says to name test classes after "the class or module under test" — allows_public_cors is the unit under test here, not a behavior or scenario, so the name follows the convention rather than violating it.

@tdilauro
tdilauro force-pushed the feature/public-cors-decorator branch from 1dae183 to 2183995 Compare August 31, 2026 19:43

@jonathangreen jonathangreen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks great! I added a couple suggestions to consider, but otherwise I think its good to go.

Comment thread src/palace/manager/api/routes.py Outdated
stacking them raises PalaceValueError at decoration time. The
advertised methods list only limits what a preflight advertises; it
does not block other methods on the actual response, so apply this
decorator only to GET/HEAD routes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It would be nice if we could enforce this, instead of just having it in the comments here. Just to prevent the footgun where someone adds the decorator and someone else expands the route to take additional methods. It might be hard to do though, since we don't know at decoration time what route this applies to. Claude has a suggestion for this, but I don't like what it suggested (I don't think it would even work).

One thing that might work is overriding add_url_rule in PalaceFlask and having it handle raising. Something like this perhaps (in api/util/flask.py):

#: The methods a route may allow while using the public wildcard CORS policy.
#: A wildcard Access-Control-Allow-Origin is only safe on requests that cannot
#: change state.
PUBLIC_CORS_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})


class PalaceFlask(flask.Flask):
    def add_url_rule(
        self,
        rule: str,
        endpoint: str | None = None,
        view_func: ft.RouteCallable | None = None,
        provide_automatic_options: bool | None = None,
        **options: Any,
    ) -> None:
        """Register a URL rule, rejecting unsafe uses of allows_public_cors.

        A route's methods are not known until it is registered, so this is the
        first point where the GET/HEAD-only requirement can be checked.
        """
        if getattr(view_func, "allows_public_cors", False):
            methods = (
                options.get("methods")
                or getattr(view_func, "methods", None)
                or ("GET",)
            )
            unsafe = {method.upper() for method in methods} - PUBLIC_CORS_METHODS
            if unsafe:
                raise PalaceValueError(
                    f"Route '{rule}' allows {', '.join(sorted(unsafe))}, so it must "
                    f"not use allows_public_cors: a wildcard "
                    f"Access-Control-Allow-Origin would let any web origin read the "
                    f"response."
                )
        super().add_url_rule(
            rule, endpoint, view_func, provide_automatic_options, **options
        )       

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great idea! I did not have a good answer for that one, so I was going to let it ride for now.

Comment thread src/palace/manager/api/routes.py Outdated

_public_cors = cross_origin(
origins="*",
methods=["GET", "HEAD", "OPTIONS"],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bonus: If you define PUBLIC_CORS_METHODS, you could use that here as well, so they stay aligned.

Comment on lines +175 to +196
@app.after_request
def add_public_cors_to_error_responses(response: Response) -> Response:
"""Back-fill the wildcard CORS header on public routes.

The allows_public_cors decorator cannot add headers to a response it
never sees. A view that raises gets its response built by the app-level
error handler, and cross-origin clients need the header on that
response to read the error body. As a safety net, the hook also covers
stacks that ignore the placement rule in allows_public_cors, where an
outer decorator returns a problem detail without calling the view.

Responses produced before routing resolves an endpoint cannot be
attributed to a view and are not back-filled: a 405 for a method the
route does not allow, and a strict_slashes redirect. Register public
CORS routes with strict_slashes=False (see library_dir_route) so a
trailing-slash mismatch never becomes an unreadable redirect.
"""
if "Access-Control-Allow-Origin" not in response.headers and request.endpoint:
view = current_app.view_functions.get(request.endpoint)
if getattr(view, "allows_public_cors", False):
response.headers["Access-Control-Allow-Origin"] = "*"
return response

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggestion: Instead of hand rolling this, perhaps we use set_cors_headers and get_cors_options so that the options we use don't drift, and we get all the guards built into these functions.

Add this above:

_PUBLIC_CORS_OPTIONS = frozendict(
    origins="*",
    methods=PUBLIC_CORS_METHODS,
    max_age=3600,
    send_wildcard=True,
    supports_credentials=False,
)

_public_cors = cross_origin(**_PUBLIC_CORS_OPTIONS)

Then in this function:

@app.after_request
def add_public_cors_to_error_responses(response: Response) -> Response:
    if "Access-Control-Allow-Origin" in response.headers or not request.endpoint:
        return response
    view = current_app.view_functions.get(request.endpoint)
    if getattr(view, "allows_public_cors", False):
        set_cors_headers(response, get_cors_options(current_app, _PUBLIC_CORS_OPTIONS))
    return response

@tdilauro
tdilauro merged commit b9bdc5e into main Sep 1, 2026
25 checks passed
@tdilauro
tdilauro deleted the feature/public-cors-decorator branch September 1, 2026 17:02
tdilauro added a commit that referenced this pull request Sep 1, 2026
## Description

Applies the new `allows_public_cors` decorator to the routes that serve
public, credential-free data: the library index, authentication
document, catalog feeds (groups, feed, navigation), search, crawlable
feeds, the MARC download page, works lookups (URN lookup, contributor,
series, permalink, recommendations, related books), analytics event
tracking (authentication there is optional, and events record with or
without a patron), and `version.json`. These routes now send
`Access-Control-Allow-Origin: *` without credentials. The authentication
document, the MARC page, and `/version.json` previously sent no CORS
headers at all. Authenticated routes (loans, holds, borrow, fulfill,
revoke, annotations, and the patron profile, device, and token
endpoints) keep the existing `allows_patron_web` allowlist behavior.

Per the decorator's placement rule, it sits outside `has_library`, so
OPTIONS preflights get full CORS headers before library resolution can
short-circuit.

Updates the `PALACE_PATRON_WEB_HOSTNAMES` documentation to match.

> [!NOTE]
> This PR is stacked atop #3680. It should not be merged until that one
has landed.

## Motivation and Context

These routes are readable by anyone without credentials, so restricting
browser origins adds no protection. It only breaks web catalog clients
whose hosts are not in the configured allowlist, which creates friction
for dev instances and new deployments. With open CORS on public routes,
a web catalog works against any Circulation Manager without
configuration; the allowlist now only governs credentialed endpoints.

Patron-specific variants of these routes (root lane redirects, loan
state on permalinks, age-based lane filtering) are reachable only
through an explicit `Authorization` header. The Palace web client sends
that header on a plain, non-credentialed fetch, so the wildcard origin
does not affect it; cookie-credentialed requests are not part of the
patron API.

## How Has This Been Tested?

New parametrized tests request each of the 18 public routes through the
route test fixture with a real `Origin` header and assert the wildcard
origin with no credentials header. A negative test asserts `/loans`
still echoes only configured allowlist origins and never the wildcard,
and a library-not-found test pins the decorator's placement outside
`has_library`. All existing route and controller tests pass.

## Checklist

- [x] I have updated the documentation accordingly.
- [x] All new and existing tests passed.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@jonathangreen jonathangreen added the feature New feature label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants