Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions VERSION_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ This is the repo-level change log for Zest module and release versions. Every co

Entries can represent coordinated product releases or independently versioned modules.

## 1.0.4

Date: 2026-07-16

- Added backend abuse controls for the public Cloud Run proxy: a Firebase App Check (Play Integrity) token verifier (`backend/app_check.py`), enforced on `/analyze` and `/chat`, gated off by default (`APP_CHECK_ENABLED=false`) so it is a no-op until the app sends tokens.
- Capped Cloud Run `--max-instances` 100 -> 10 to bound the Vertex cost/quota blast radius.
- Added `documentation/15-backend-abuse-controls.md` with exposure, mitigations, and the ordered enforcement rollout.
- No Android module change; enforcement stays off until a token-sending app build has adoption.
- Bumped `backend-proxy` to 1.0.4 and `documentation` to 1.0.1; updated `module_versions.json` and `documentation/12-module-versioning.md` to match.

## 1.0.3

Date: 2026-07-13
Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY backend/main.py backend/prompt.py module_versions.json ./
COPY backend/main.py backend/prompt.py backend/app_check.py module_versions.json ./
COPY backend/prompts ./prompts

EXPOSE 8080
Expand Down
17 changes: 13 additions & 4 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ On model/auth/timeout failure the service returns HTTP 502 with
| `GEMINI_CHAT_MAX_OUTPUT_TOKENS` | `2048` | Result-chat output cap (shared by thinking + answer) |
| `ENABLE_OPENAPI_DOCS` | `false` | Enables `/docs`, `/redoc`, and `/openapi.json` only for local/dev use |
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated browser origins allowed to call the API; empty disables CORS middleware |
| `APP_CHECK_ENABLED` | `false` | When `true`, `/analyze` and `/chat` require a valid Firebase App Check token (`X-Firebase-AppCheck` header). Off = no attestation check (current shipped-app posture). |
| `FIREBASE_PROJECT_NUMBER` | empty | Required when `APP_CHECK_ENABLED=true`; the project number (`894254677159`) used to validate the token audience/issuer. |
| `APP_CHECK_JWKS_URL` | Firebase JWKS | Override the App Check public-key endpoint (testing only). |
| `PORT` | `8080` | Server port (set by Cloud Run) |

## Local development
Expand Down Expand Up @@ -192,10 +195,16 @@ Deploy from the repository root so the root `Dockerfile` and root `module_versio

## Security notes

- **Known deferred risk:** `--allow-unauthenticated` leaves `/analyze` and `/chat` publicly callable. This is
accepted only as a temporary testing or limited-rollout posture. Before broad production launch,
add real abuse controls such as Firebase App Check / Play Integrity, Firebase Auth, API Gateway
+ IAM, Cloud Armor, per-device quotas, or another approved mechanism.
- **Public access is still `--allow-unauthenticated`.** `/analyze` and `/chat` remain publicly callable at
the Cloud Run IAM layer (removing this now would break the shipped app, which sends no credential).
Two mitigations are in place, and the plan to close the gap is tracked in
`documentation/15-backend-abuse-controls.md`:
- **Blast radius capped:** Cloud Run `--max-instances=10` bounds how many paid Vertex calls a flood can
trigger. This limits cost/quota damage; it does not authenticate callers.
- **App Check verifier present but gated OFF** (`APP_CHECK_ENABLED=false`). `app_check.py` verifies a
Firebase App Check (Play Integrity) token when enabled. It stays a no-op until the Android app ships
tokens and the flag is flipped — see the rollout order in the doc above. This is the app-layer
attestation control; it works alongside `--allow-unauthenticated` at the IAM layer.
- **No static mobile secret:** do not protect these endpoints with a secret compiled into the Android
app; it can be extracted and replayed.
- **Docs are disabled by default.** Set `ENABLE_OPENAPI_DOCS=true` only for local development or
Expand Down
96 changes: 96 additions & 0 deletions backend/app_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Firebase App Check (Play Integrity) token verification for the Zest backend.

This is the abuse control called for before broad rollout: verifying an App Check
token is what distinguishes a genuine, unmodified instance of the Android app from
a script hitting the public Cloud Run URL. The Android app obtains the token via the
Play Integrity provider and sends it in the `X-Firebase-AppCheck` header; this module
verifies it before `/analyze` or `/chat` reach the paid Vertex call.

Gated OFF by default (`APP_CHECK_ENABLED`). It stays a no-op until BOTH the backend
flag is on AND the shipped app sends tokens, so it can be deployed safely ahead of
the client change without breaking the live app. See
`documentation/15-backend-abuse-controls.md` for the rollout order.
"""

from __future__ import annotations

import logging
import os
from typing import Optional

from fastapi import Header, HTTPException

logger = logging.getLogger("ultraprocessed-ai-proxy.appcheck")

APP_CHECK_ENABLED = os.getenv("APP_CHECK_ENABLED", "false").strip().lower() == "true"
# Firebase project number (== GCP project number when Firebase is added to this project).
FIREBASE_PROJECT_NUMBER = os.getenv("FIREBASE_PROJECT_NUMBER", "").strip()
# Stable Google endpoint serving the App Check public keys; override only for testing.
APP_CHECK_JWKS_URL = os.getenv(
"APP_CHECK_JWKS_URL", "https://firebaseappcheck.googleapis.com/v1/jwks"
)
_ISSUER_PREFIX = "https://firebaseappcheck.googleapis.com/"

_jwks_client = None


def _get_jwks_client():
"""Cache the JWKS client; PyJWKClient caches the fetched keys internally."""
global _jwks_client
if _jwks_client is None:
from jwt import PyJWKClient

_jwks_client = PyJWKClient(APP_CHECK_JWKS_URL, cache_keys=True)
return _jwks_client


def verify_app_check_token(token: str) -> dict:
"""Verify a Firebase App Check JWT and return its claims, or raise on any failure.

Checks RS256 signature against Google's published keys, plus the audience and
issuer bound to this project. Monkeypatched in tests so the suite needs no
network or real tokens.
"""
import jwt

signing_key = _get_jwks_client().get_signing_key_from_jwt(token).key
return jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=f"projects/{FIREBASE_PROJECT_NUMBER}",
issuer=f"{_ISSUER_PREFIX}{FIREBASE_PROJECT_NUMBER}",
leeway=10, # small clock-skew tolerance
options={"require": ["exp", "iat"]},
)


def require_app_check(x_firebase_appcheck: Optional[str] = Header(default=None)) -> None:
"""FastAPI dependency: no-op when disabled, enforces a valid token when enabled.

Fails CLOSED — if enforcement is on but misconfigured, requests are denied, never
allowed through unchecked.
"""
if not APP_CHECK_ENABLED:
return
if not FIREBASE_PROJECT_NUMBER:
logger.error("APP_CHECK_ENABLED=true but FIREBASE_PROJECT_NUMBER is unset; denying request.")
raise HTTPException(
status_code=500,
detail={"error": "app_check_misconfigured", "message": "App attestation is misconfigured."},
)
if not x_firebase_appcheck:
raise HTTPException(
status_code=401,
detail={"error": "app_check_required", "message": "App attestation is required."},
)
try:
verify_app_check_token(x_firebase_appcheck)
except HTTPException:
raise
except Exception as exc: # bad signature, expired, wrong aud/iss, malformed token
logger.warning("App Check token rejected: %s", type(exc).__name__)
raise HTTPException(
status_code=401,
detail={"error": "app_check_invalid", "message": "App attestation failed."},
)
7 changes: 4 additions & 3 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
from pathlib import Path
from typing import Any, Optional

from fastapi import FastAPI, HTTPException
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, ConfigDict, Field

from app_check import require_app_check
from prompt import (
build_chat_prompt,
build_full_analysis_prompt,
Expand Down Expand Up @@ -530,7 +531,7 @@ def version() -> dict:
return MODULE_VERSION_MANIFEST


@app.post("/analyze")
@app.post("/analyze", dependencies=[Depends(require_app_check)])
def analyze(req: AnalyzeRequest) -> dict:
try:
operation = (req.type or "").strip().lower()
Expand Down Expand Up @@ -567,7 +568,7 @@ def analyze(req: AnalyzeRequest) -> dict:
)


@app.post("/chat")
@app.post("/chat", dependencies=[Depends(require_app_check)])
def chat(req: ChatRequest) -> dict:
try:
return analyze_chat(req)
Expand Down
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ pydantic>=2.9,<3.0
# Keep local and Cloud Run installs on a wheel-backed version; newer releases may require a
# Rust/Cargo version unavailable in some build environments.
cryptography==44.0.3
# App Check (Play Integrity) token verification; uses the pinned cryptography above for RS256.
pyjwt[crypto]>=2.8,<3.0
104 changes: 104 additions & 0 deletions backend/tests/test_app_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""App Check enforcement tests. No network or real tokens: the verifier is monkeypatched.

Enforcement is gated behind module-level flags on `app_check`, so tests toggle those
attributes (mirroring how test_app.py monkeypatches main.call_gemini) rather than env.
"""

from __future__ import annotations

from fastapi.testclient import TestClient

import app_check
import main

client = TestClient(main.app)

ANALYZE_BODY = {"type": "analysis", "ingredient_text": "water, sugar, palm oil"}
FULL_RESPONSE = {
"nova": {
"containsConsumableFoodItem": True,
"novaGroup": 4,
"summary": "s",
"rejectionReason": "",
"confidence": 0.8,
"warnings": [],
},
"ingredients": {"correctedIngredients": [], "ultraProcessedIngredients": [], "confidence": 0.0, "warnings": []},
"allergens": {"allergens": [], "confidence": 0.0, "warnings": []},
}


def enable(monkeypatch, project_number="894254677159"):
monkeypatch.setattr(app_check, "APP_CHECK_ENABLED", True)
monkeypatch.setattr(app_check, "FIREBASE_PROJECT_NUMBER", project_number)


def stub_model(monkeypatch):
calls = []
monkeypatch.setattr(main, "call_full_analysis", lambda j: (calls.append(j), (FULL_RESPONSE, {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0}))[1])
return calls


def test_disabled_by_default_no_header_needed(monkeypatch):
# Default posture: enforcement off, so the shipped app (which sends no token) still works.
assert app_check.APP_CHECK_ENABLED is False
stub_model(monkeypatch)
r = client.post("/analyze", json=ANALYZE_BODY)
assert r.status_code == 200


def test_enabled_missing_header_rejected_before_model(monkeypatch):
enable(monkeypatch)
calls = stub_model(monkeypatch)
r = client.post("/analyze", json=ANALYZE_BODY)
assert r.status_code == 401
assert r.json()["detail"]["error"] == "app_check_required"
assert calls == [] # never reached the paid Vertex call


def test_enabled_invalid_token_rejected_before_model(monkeypatch):
enable(monkeypatch)
calls = stub_model(monkeypatch)

def boom(_token):
raise ValueError("bad signature")

monkeypatch.setattr(app_check, "verify_app_check_token", boom)
r = client.post("/analyze", json=ANALYZE_BODY, headers={"X-Firebase-AppCheck": "garbage"})
assert r.status_code == 401
assert r.json()["detail"]["error"] == "app_check_invalid"
assert calls == []


def test_enabled_valid_token_passes(monkeypatch):
enable(monkeypatch)
stub_model(monkeypatch)
monkeypatch.setattr(app_check, "verify_app_check_token", lambda token: {"sub": "app-id"})
r = client.post("/analyze", json=ANALYZE_BODY, headers={"X-Firebase-AppCheck": "valid"})
assert r.status_code == 200
assert r.json()["type"] == "analysis"


def test_enabled_but_misconfigured_fails_closed(monkeypatch):
# Flag on but no project number -> deny, never fall open.
enable(monkeypatch, project_number="")
calls = stub_model(monkeypatch)
r = client.post("/analyze", json=ANALYZE_BODY, headers={"X-Firebase-AppCheck": "whatever"})
assert r.status_code == 500
assert r.json()["detail"]["error"] == "app_check_misconfigured"
assert calls == []


def test_chat_is_also_protected(monkeypatch):
enable(monkeypatch)
called = []
monkeypatch.setattr(main, "call_gemini", lambda p: called.append(p))
r = client.post("/chat", json={"question": "hi", "result": {"productName": "x"}})
assert r.status_code == 401
assert called == []


def test_healthz_stays_open_when_enabled(monkeypatch):
# Cloud Run health checks and uptime probes must not need a token.
enable(monkeypatch)
assert client.get("/healthz").status_code == 200
2 changes: 1 addition & 1 deletion documentation/09-todo-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Zest currently uses:
- Create typed screen arguments for result, disclaimer, and analysis flows.
- Move analysis launch side effects out of screen composition where practical.
- Design privacy-safe operational diagnostics that never include OCR text, images, prompts, or model responses.
- Add production abuse controls for public backend `/analyze` and `/chat` endpoints before broad launch; App Check / Play Integrity is a candidate, but not part of the current implementation.
- Finish production abuse controls for public backend `/analyze` and `/chat` before broad launch. In progress (see [15-backend-abuse-controls.md](15-backend-abuse-controls.md)): max-instances capped, and a Firebase App Check / Play Integrity verifier is landed in the backend but gated off. Remaining: Firebase/Play setup, the Android token header, then flip `APP_CHECK_ENABLED`.
- Add provider usage parsing when provider responses include reliable token/cost metadata.
- Add migration tests only if a future approved feature restores local persistence.
- Add UI snapshot or screenshot tests for scanner, settings, results, and error states.
Expand Down
4 changes: 2 additions & 2 deletions documentation/12-module-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The root manifest is the production source of truth. The root version log record
| Module ID | Kind | Path | Version | Runtime Surface |
| --- | --- | --- | --- | --- |
| `android-app` | android-gradle-module | `:app` | `1.0.2` | Android `BuildConfig.VERSION_NAME` |
| `backend-proxy` | cloud-run-service | `backend` | `1.0.2` | FastAPI `app.version` and `GET /version` |
| `backend-proxy` | cloud-run-service | `backend` | `1.0.4` | FastAPI `app.version` and `GET /version` |
| `analysis` | android-package | `app/src/main/java/com/b2/ultraprocessed/analysis` | `1.0.1` | module version manifest |
| `barcode` | android-package | `app/src/main/java/com/b2/ultraprocessed/barcode` | `1.0.0` | module version manifest |
| `camera` | android-package | `app/src/main/java/com/b2/ultraprocessed/camera` | `1.0.0` | module version manifest |
Expand All @@ -25,7 +25,7 @@ The root manifest is the production source of truth. The root version log record
| `storage` | android-package | `app/src/main/java/com/b2/ultraprocessed/storage` | `1.0.0` | module version manifest |
| `ui` | android-package | `app/src/main/java/com/b2/ultraprocessed/ui` | `1.0.1` | module version manifest |
| `backend-prompts` | backend-contract-assets | `backend/prompts` | `1.0.3` | backend-owned prompt files |
| `documentation` | repo-documentation | `documentation` | `1.0.0` | repo documentation and handoff docs |
| `documentation` | repo-documentation | `documentation` | `1.0.1` | repo documentation and handoff docs |
| `agent-context` | repo-agent-guidance | `AGENTS.md` | `1.0.0` | `AGENTS.md` and `.cursor/rules/zest-project.mdc` |

## Production Tracking Contract
Expand Down
2 changes: 1 addition & 1 deletion documentation/13-agent-brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Runtime flow:
- Privacy: do not persist or log scan images, OCR text, ingredients, results, chat, usage, or failures.
- Storage archive: former Room/history code stays under `documentation/code-archive/session_only_storage/` and must not be restored without a product privacy decision.
- UI: allergens are separate from processing markers; ingredient bubbles are based on corrected ingredient names and ultra-processed marker matches.
- Production risk: public unauthenticated backend endpoints are a known deferred risk and require abuse controls before broad launch.
- Production risk: public unauthenticated backend endpoints are a known deferred risk. The backend App Check (Play Integrity) verifier is landed but gated off and `--max-instances` is capped; finish enforcement (Firebase/Play setup, Android token header, then `APP_CHECK_ENABLED=true`) before broad launch. See `documentation/15-backend-abuse-controls.md`.

## Where To Look

Expand Down
2 changes: 1 addition & 1 deletion documentation/14-decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ This file summarizes the decisions most likely to guide future agent work. Detai
| Keep allergens separate from processing markers. | Active | Allergen detection is not the same signal as ultra-processing. | Do not color ingredients red because they are allergens; show allergens in their own block. | `documentation/04-classification-analysis.md`, `documentation/08-llm-api-contracts.md` |
| Track module versions in the repo root. | Active | Agents and releases need an objective production version surface that is not hidden inside the backend service directory. | Update root `module_versions.json`, root `VERSION_LOG.md`, and `documentation/12-module-versioning.md` when module behavior changes. | `documentation/12-module-versioning.md`, `app/build.gradle.kts` |
| Require every change to leave a summarized handoff trail. | Active | Future agents need the decision philosophy, not only the code diff. | Each change must update relevant docs, decision log, agent brief, and version log when applicable. | `AGENTS.md`, `.cursor/rules/zest-project.mdc` |
| Treat public unauthenticated backend access as deferred risk. | Open caution | Current Cloud Run deployment can be public during testing/limited rollout, but broad production needs abuse controls. | Add App Check / Play Integrity, auth/IAM gateway, Cloud Armor, quotas, rate limits, or equivalent before broad launch. | `backend/README.md`, `documentation/09-todo-roadmap.md` |
| Treat public unauthenticated backend access as deferred risk. | Open caution | Current Cloud Run deployment can be public during testing/limited rollout, but broad production needs abuse controls. | Backend half landed: App Check (Play Integrity) verifier in `backend/app_check.py`, gated off (`APP_CHECK_ENABLED=false`); `--max-instances` capped to bound cost. Remaining before broad launch: Firebase/Play setup, Android token header, then enable `APP_CHECK_ENABLED` (only after a token-sending app build has adoption, or installed apps break). | `backend/README.md`, `documentation/09-todo-roadmap.md`, `documentation/15-backend-abuse-controls.md` |
| Prefer compact agent bootstrap docs over duplicated handbook content. | Active | Long-form docs already exist; agents need a fast route into current constraints. | Keep `documentation/13-agent-brief.md`, this log, `AGENTS.md`, and Cursor rules short and link outward. | `documentation/README.md` |
Loading
Loading