diff --git a/CHANGELOG.md b/CHANGELOG.md index eba403c..d55e200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.1] + +- Redact log response for exchange and refresh methods. + ## [1.0.0] - **Breaking:** rename the verify result field `new_id_token_response` to `invalid_token_response`, matching the `exchange_using_token_exchange` and `admin_graphql_request` parameters. Update any code that reads this field: diff --git a/shopify_app/_version.py b/shopify_app/_version.py index d7e5eb3..e5eefef 100644 --- a/shopify_app/_version.py +++ b/shopify_app/_version.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/shopify_app/exchange/client_credentials.py b/shopify_app/exchange/client_credentials.py index 860d515..23e6336 100644 --- a/shopify_app/exchange/client_credentials.py +++ b/shopify_app/exchange/client_credentials.py @@ -22,7 +22,7 @@ RequestInput, Res, ) -from ..utils import _get_user_agent, redact_http_log +from ..utils import _get_user_agent, redact_http_log, redact_http_response_body from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext from ._response_builders import build_network_error_response from ._validation import validate_shop @@ -86,8 +86,12 @@ def exchange_using_client_credentials( response_headers = ( dict(response.headers) if hasattr(response, "headers") else {} ) + # The token endpoint response carries the newly issued + # credentials, so redact them before this goes into a log. res_obj = Res( - status=status_code, body=response_body, headers=response_headers + status=status_code, + body=redact_http_response_body(response_body), + headers=response_headers, ) # Handle 200 success @@ -353,8 +357,12 @@ async def exchange_using_client_credentials_async( response_headers = ( dict(response.headers) if hasattr(response, "headers") else {} ) + # The token endpoint response carries the newly issued + # credentials, so redact them before this goes into a log. res_obj = Res( - status=status_code, body=response_body, headers=response_headers + status=status_code, + body=redact_http_response_body(response_body), + headers=response_headers, ) # Handle 200 success diff --git a/shopify_app/exchange/token_exchange.py b/shopify_app/exchange/token_exchange.py index ab2cf07..07ee38e 100644 --- a/shopify_app/exchange/token_exchange.py +++ b/shopify_app/exchange/token_exchange.py @@ -26,7 +26,13 @@ TokenExchangeResult, User, ) -from ..utils import _get_attr, _get_user_agent, _to_res, redact_http_log +from ..utils import ( + _get_attr, + _get_user_agent, + _to_res, + redact_http_log, + redact_http_response_body, +) from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext from ._response_builders import build_network_error_response from ._validation import validate_client_id @@ -143,8 +149,12 @@ def token_exchange( response_headers = ( dict(response.headers) if hasattr(response, "headers") else {} ) + # The token endpoint response carries the newly issued + # credentials, so redact them before this goes into a log. res_obj = Res( - status=status_code, body=response_body, headers=response_headers + status=status_code, + body=redact_http_response_body(response_body), + headers=response_headers, ) # Handle 200 success @@ -790,8 +800,12 @@ async def token_exchange_async( response_headers = ( dict(response.headers) if hasattr(response, "headers") else {} ) + # The token endpoint response carries the newly issued + # credentials, so redact them before this goes into a log. res_obj = Res( - status=status_code, body=response_body, headers=response_headers + status=status_code, + body=redact_http_response_body(response_body), + headers=response_headers, ) # Handle 200 success diff --git a/shopify_app/utils/__init__.py b/shopify_app/utils/__init__.py index cf9e6a0..58633e8 100644 --- a/shopify_app/utils/__init__.py +++ b/shopify_app/utils/__init__.py @@ -4,7 +4,7 @@ from .headers import _normalize_headers from .input_converters import _get_attr, _to_res -from .redact import redact_http_log +from .redact import redact_http_log, redact_http_response_body from .user_agent import _get_user_agent __all__ = [ @@ -13,4 +13,5 @@ "_get_attr", "_to_res", "redact_http_log", + "redact_http_response_body", ] diff --git a/shopify_app/utils/redact.py b/shopify_app/utils/redact.py index 25197a3..12c123f 100644 --- a/shopify_app/utils/redact.py +++ b/shopify_app/utils/redact.py @@ -9,7 +9,19 @@ from ..types import RequestInput _REDACTED = "[REDACTED]" -_SENSITIVE_BODY_FIELDS = {"client_secret", "subject_token", "refresh_token"} +_SENSITIVE_BODY_FIELDS = { + "client_secret", + "subject_token", + "refresh_token", + "access_token", +} +# Fields an OAuth token endpoint returns that are reusable credentials. A debug +# log is a lower-trust sink than the app runtime, so these must never reach it. +_SENSITIVE_RESPONSE_BODY_FIELDS = { + "access_token", + "refresh_token", + "client_secret", +} _SENSITIVE_HEADER_FIELDS = { "x-shopify-access-token", "authorization", @@ -35,7 +47,8 @@ def redact_http_log(req: RequestInput) -> RequestInput: """Redact sensitive values from a req object before including it in HTTP logs. Redacts: - - Request body fields: client_secret, subject_token, refresh_token + - Request body fields: client_secret, subject_token, refresh_token, + access_token - Request headers: X-Shopify-Access-Token, Authorization (case-insensitive) - Request URL params: signature, id_token, hmac (case-insensitive) """ @@ -63,3 +76,37 @@ def redact_http_log(req: RequestInput) -> RequestInput: pass return cast(RequestInput, result) + + +def redact_http_response_body(body: str) -> str: + """Redact issued OAuth credentials from a response body before logging it. + + A 200 from the OAuth token endpoint carries the ``access_token`` and + ``refresh_token`` that were just issued. Both are reusable credentials, so + logging the response verbatim would let anyone with log access replay them. + Every other field is preserved so the log stays useful for debugging. + + The body is only re-serialized when something was actually redacted, so a + body that carries no credentials keeps its original formatting. + """ + if not isinstance(body, str): + return body + + try: + parsed = json.loads(body) + except (json.JSONDecodeError, TypeError): + return body + + if not isinstance(parsed, dict): + return body + + modified = False + for field in _SENSITIVE_RESPONSE_BODY_FIELDS: + if field in parsed: + parsed[field] = _REDACTED + modified = True + + if not modified: + return body + + return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)