Please do not open a public issue for a security problem. A public report tells everyone about the flaw before there is a fix available.
Report privately instead, using either:
- GitHub private vulnerability reporting — go to the repository's Security tab and choose Report a vulnerability. This opens a private advisory visible only to the maintainers. (Repository owners: enable this under Settings → Code security and analysis → Private vulnerability reporting.)
- Email the maintainers at the address listed on the repository's GitHub
profile, with
SECURITYin the subject line.
Please include:
- what the issue is and which component is affected;
- the steps to reproduce it, or a proof of concept;
- the impact you believe it has;
- the version or commit you tested.
What to expect: an acknowledgement within 5 working days, an assessment within 10 working days, and credit in the fix notes unless you would rather stay anonymous. This is a volunteer-maintained project, so there is no paid bounty.
Only the latest commit on main receives security fixes.
The defaults in this repository are chosen so a fresh clone runs locally with no configuration. Those same defaults are not safe on a public network. Work through this list before exposing an instance to the internet.
-
DEBUG=false. With debug on, Django serves full tracebacks — including settings and local variables — to anyone who triggers an error. -
DJANGO_SECRET_KEYis set to a fresh random value and is not shared between environments. Generate one with:python -c "from django.core.management.utils import get_random_secret_key as k; print(k())". WithDEBUG=falsethe app refuses to start without it; it will never fall back to the development key. -
ALLOWED_HOSTSlists real hostnames.*is rejected outright whenDEBUG=false. -
CORS_ALLOWED_ORIGINSlists only your own frontend origins. The API sends credentials, so a wildcard would let any website call it on a visitor's behalf.CORS_ALLOW_ALL_ORIGINSis never enabled by this code. -
RECAPTCHA_SECRET_KEYis set. Required whenDEBUG=false. Without it the contact form is an open, unauthenticated write endpoint. -
OPENAI_API_KEYis set, scoped to this application, and has a spend limit. Every recording that reaches the API costs money.
- Terminate TLS in front of the app. With
DEBUG=falsethe app sets HSTS,SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE,X-Frame-Options: DENYand content-type nosniff automatically. - If a proxy already redirects HTTP to HTTPS, set
SECURE_SSL_REDIRECT=falseto avoid a redirect loop. - Run
python manage.py check --deployand resolve every warning.
- Use the provided Dockerfiles, or an equivalent WSGI server. Never
deploy
manage.py runserveror the Vite dev server. Both images run as a non-root user. - Do not serve
MEDIA_ROOTfrom Django in production. The URL pattern for it is registered only whenDEBUGis on. - Restrict
/admin/by network, or disable it if unused. There is no rate limiting on the admin login. - Tune
API_ANON_THROTTLE_RATE(default30/minute). The audio endpoint is unauthenticated and each call spends money at OpenAI. - Cap upload size at the proxy too.
AUDIO_MAX_UPLOAD_BYTESis enforced by the app, but a proxy limit stops large bodies earlier.
Deploying this application means processing other people's personal data. Know what it collects before you go live.
| What | Where it is stored | Retention |
|---|---|---|
| Contact form: first name, last name, company, email, message, product interests | Database table voiceApp_appformsubmission |
Indefinite — no automatic deletion |
Voice recording (latest_input.mp3) |
MEDIA_ROOT/<session-uuid>/ |
AUDIO_RETENTION_HOURS, default 24h |
Generated speech (latest_output.mp3) |
MEDIA_ROOT/<session-uuid>/ |
AUDIO_RETENTION_HOURS, default 24h |
Transcript history — the last AUDIO_HISTORY_LENGTH transcriptions and responses |
MEDIA_ROOT/<session-uuid>/history.json |
AUDIO_RETENTION_HOURS, default 24h |
Notes on the design:
- The session id is not an account. It is a random UUID minted in the
browser's
localStorageand rotated every 24 hours. It groups one visitor's recordings; it is not tied to a name, an email, or an IP address. - The server does not log IP addresses itself. Your reverse proxy almost certainly does. That log is personal data too — set a retention period on it.
- Recordings are sent to OpenAI for transcription and text generation, and
the generated text is sent to Google Translate's TTS endpoint by
gTTS. Both are third-party processors. Say so in your privacy notice. - The contact form transmits a token to Google for reCAPTCHA verification.
Session data expires on its own, and the sweep runs on every request. To purge sooner, or to run it from a scheduled job:
python manage.py purge_sessions # delete anything past retention
python manage.py purge_sessions --all # delete every stored sessionFor the strongest posture, set AUDIO_PERSIST_RECORDINGS=false. Recordings are
then processed and deleted immediately, leaving only the rolling text history.
Contact form submissions have no automatic expiry. If you deploy this, decide
on a retention period and delete rows on that schedule — via the Django admin, or
a scheduled AppFormSubmission.objects.filter(submitted_at__lt=...).delete().
These are design constraints of the project, not undisclosed vulnerabilities:
- Both API endpoints are unauthenticated. Protection is rate limiting and reCAPTCHA, not identity. Anyone who can reach the audio endpoint can spend your OpenAI quota.
- The session id is client-supplied. It is validated as a UUID, so it cannot
be used to escape
MEDIA_ROOT, but a caller who guesses or replays another session's UUID would read that session's transcript history. VITE_*variables ship inside the JavaScript bundle and are readable by anyone. This is correct for the reCAPTCHA site key; never put a secret there.