Skip to content

fix(security): implement review follow-up hardening#159

Draft
marpisco wants to merge 34 commits into
mainfrom
dev
Draft

fix(security): implement review follow-up hardening#159
marpisco wants to merge 34 commits into
mainfrom
dev

Conversation

@marpisco

@marpisco marpisco commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Implements the security review follow-up fixes across auth, CSRF/destructive actions, XSS hardening, OAuth/TOTP handling, rate limiting, and reservation/admin flows.
  • Adds trusted referer handling, per-user email-code attempt tracking, HTML sanitization for post-reservation content, and sensitive request redaction in admin logs.
  • Fixes follow-up regressions found during rereview, including sala link XSS validation and rate-limit bind type handling.

Test Plan

  • php tests/security_helpers_test.php
  • Full syntax lint over all non-vendor PHP files with php -l
  • Fresh security rereview completed for accepted follow-up scope; no actionable issues found there.

Notes

  • src/config.php is ignored/untracked local config and intentionally not part of this PR.
  • Public visibility of reservation details to authenticated users is intentional because the calendar is public within the app.
  • OAuth debug details, validated Host header behavior, and GET logout behavior are intentional per project requirements.

marpisco added 16 commits June 6, 2026 21:37
…, 8, 10, 11, 12, 13, 14)

Round 2 of the security review. Implements the remaining fixes from the
original review (PR #149) that the owner asked to be addressed.

Authentication fixes (login/index.php):
- Issue 1: new users no longer get stuck — OTP code generation and email
  sending now run for both new and existing users. Previously the code
  was only generated in the else branch, so freshly created users were
  shown "check your email" but never received a code.
- Issue 2: per-IP rate limiting on auth flows (new func/rate_limit.php):
    send_code         10 attempts / hour / IP
    verify_code        5 wrong attempts → invalidate the user's OTP
                       (no IP lockout per spec, but the user must request
                       a fresh code to continue)
    verify_totp        5 wrong attempts → 15-minute IP block
    verify_totp_setup  5 wrong attempts → 15-minute IP block
- Issue 7: OAuth IdentityProviderException no longer leaks the raw
  exception message. Renders a generic error page with a collapsible
  <details>Debug</details> block; full message still logged server-side.
- Issue 10: session validity changed from 3600 → 1800 to match
  session_config.php's gc_maxlifetime.
- Issue 11: start_authenticated_session() now calls
  regenerate_csrf_token() so a token captured pre-login cannot be reused.
- Issue 12: TOTP QR code now rendered locally with chillerlan/php-qrcode
  (added to composer.json). The previous api.qrserver.com call sent
  the otpauth:// URL — and therefore the TOTP secret — to a third
  party that could go offline, log the secret, or be MITM'd by a
  corporate firewall.
- Issue 14: server-side filter_var($email, FILTER_VALIDATE_EMAIL) on
  send_code.

CSRF (func/csrf.php):
- Added regenerate_csrf_token() helper.

Race condition (src/db.php, login/index.php):
- Issue 13: first-user-admin assignment is now race-safe via
  INSERT ... ON DUPLICATE KEY UPDATE on a config row. The first
  thread to insert wins; concurrent threads update the existing row
  and lose. A new default config key 'first_user_admin_id' is added.
- IS_FIRST_RUN constant updated to consider whether the admin slot
  has been claimed.

GET → POST conversions (Issue 3):
- admin/pedidos.php: aprovar and rejeitar now require POST + CSRF.
  The PHP handler reads from POST/GET via a unified $src array.
  JS submits a hidden POST form with the action and parameters.
  bulk_approve / bulk_reject were already POST and unchanged.
- reservar/manage.php: apagar now requires POST + CSRF.
- reservas/index.php: delete button submits a hidden POST form
  with a CSRF token (the page now includes func/csrf.php and
  exposes the token to JS via window.__csrfToken).

Session security (Issue 6):
- 17 files now require_once func/session_config.php before
  session_start(): index.php, login/index.php, reservar/*,
  reservas/index.php, admin/index.php, admin/relatorios.php,
  and all 9 admin/api/* files. Applies the secure cookie/session
  settings (httponly, samesite=Lax, secure-if-HTTPS, strict mode,
  48-char session ID, 30-min GC maxlifetime) to every entry point.

TOTP secret exposure (Issue 8):
- admin/api/users_search.php: replaced the raw totp_secret column
  in the SELECT with a computed (totp_secret IS NOT NULL AND
  totp_secret != '') AS hasTotp boolean, so the TOTP secret
  never leaves the database.

New database table:
- rate_limits (ip, action, attempts, window_start, blocked_until)
  with composite primary key (ip, action).

Not addressed in this commit (per owner direction):
- Issue 4  (audit log POST/GET data) — kept as expected
- Issue 5  (proxy IP headers) — server-side concern
- Issue 9  (cookie not cleared on session_destroy) — not relevant
- Issue 15 (sidebarLink escaping)
- Issue 16 (config validation)
- Issue 17 (explicit $db->close())
- Issue 18 (unescaped $_GET in logs)
- Issue 19 (recipients_preview type coercion)
…e.php

The recent commit converted the 'apagar' action to POST+CSRF, but the
old GET-based switch case at line 607 (lines 607-669) was left in
place. A GET request to /reservar/manage.php?subaction=apagar&...
bypassed CSRF entirely and still deleted reservations.

The POST handler at the top of the file (lines 227-242) is the
authoritative code path. The switch case is now a deliberate no-op
that falls through to the detail-view case, so a GET request
shows the reservation detail instead of deleting it.

Fixes finding from second security review (C2).
… broke the race claim)

The race-safety fix from the previous commit relied on
INSERT ... ON DUPLICATE KEY UPDATE config_value = config_value
returning affected_rows === 1 for the winning thread. However,
src/db.php pre-seeded 'first_user_admin_id' with an empty value
in \, so the row always existed and the
'ON DUPLICATE KEY UPDATE' branch was a no-op. MySQL reports
affected_rows = 0 for a no-op update, so \ was always
false and the first user was never made admin.

This commit removes 'first_user_admin_id' from the default
seed list. The row is now only inserted when create_pending_user
actually claims it, restoring the intended race-safe behavior:
the first thread to insert gets affected_rows = 1, subsequent
threads get affected_rows = 2 (or 0) and lose.

Fixes finding C3 from the second security review.
…ion only in URL)

The recent commit converted aprovar/rejeitar to POST+CSRF and read
\ from \ (= \ for POSTs). The submitBulkAction()
JS helper was unchanged: it built a POST form with the subaction
in the URL (?subaction=bulk_approve) and the reservations array in
the body. The PHP handler therefore saw \['subaction']=null
and the switch matched nothing, so the bulk actions silently
did nothing — the user saw a success modal and the page re-rendered
with the same pending list.

Two changes fix this:
1. submitBulkAction() now puts subaction in the body via a hidden
   input, so it round-trips in \ as expected.
2. The PHP handler also falls back to \['subaction'] when
   \['subaction'] is missing, so existing callers that put
   it in the URL still work.

Fixes finding C2 (second review) — bulk actions via the UI.
… TOTP lockout

The record_attempt() function used a hard-coded 'INTERVAL 1 HOUR'
when deciding whether to reset the attempts counter. check_rate_limit()
correctly used the caller-supplied \ (900 for TOTP,
3600 for send_code), but record_attempt() did not. This created a
45-minute window of unlimited TOTP attempts after a 15-min block
expired: the 15-min window in check_rate_limit() expired and the
function returned true, while record_attempt() never reset the
counter (because 15 < 60 min). After the block expired, the user
could try TOTP codes indefinitely until the actual 1-hour mark.

Fix: pass \ to record_attempt() and use INTERVAL ? SECOND
in the SQL, so the reset threshold matches the configured window.
All four callers in login/index.php (send_code, verify_code,
verify_totp, verify_totp_setup) now pass their actual window.

Fixes Bug #1 from the second review.
…to POST+CSRF

admin/users.php, admin/salas.php, and admin/tempos.php each had a
destructive 'apagar' case (and users.php also had 'removetotp')
reachable via a plain GET request, with the target id in the URL
query string. The global CSRF check in admin/index.php only
applies to POSTs, so a top-level navigation to e.g.
/admin/users.php?action=apagar&id=<victim> would silently delete
the user (or strip their TOTP, enabling account takeover via
email-OTP login) without any CSRF protection.

For each file:
1. A guard at the top of the switch rejects non-POST requests for
   destructive actions with HTTP 405.
2. The action handler now reads the id from \ (the form body)
   instead of \.
3. The HTML and JS delete/removetotp links are replaced with
   inline POST forms. The global CSRF injector in admin/index.php
   adds a csrf_token hidden input on submit.

The chained 'removetotp' + email-OTP takeover vector on another
admin is now closed: an attacker who can click a link as the
admin still cannot bypass the global POST+CSRF check.

Fixes C1 from the second security review.
\$_SERVER['REQUEST_URI'] echoed raw into an HTML attribute lets an
attacker craft a URL that injects a script tag into the page when
the victim clicks the link while authenticated. Escape the value.

Fixes H1 from the second review.
…users.php)

addslashes() does not escape '</script>'. An admin (or attacker
with admin session) who sets the internal_email_domain config
value to '</script><script>alert(1)</script>' would inject
arbitrary JS that runs in every admin viewing the user list.

Use json_encode() with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS
| JSON_HEX_QUOT instead. The resulting value is a properly-quoted
JS string literal that cannot break out of the script tag.

Fixes H3 from the second review.
…lues

Several admin/user pages echoed database-fetched sala and tempo
ids and names directly into HTML attributes and element content
without htmlspecialchars. Admin-controlled names (e.g. an admin
sets sala nome to '><script>alert(1)</script> via admin/salas.php)
would execute JS in every user visiting a page that lists the
affected sala.

Wrap id and nome interpolations with htmlspecialchars(..., ENT_QUOTES,
'UTF-8') in:
- reservar/index.php (sala select)
- admin/materiais.php (sala select, two places)
- admin/reservaemmassa.php (tempo checkbox value/id)

Fixes H2 from the second review.
The blocked-email check in the email-OTP path did not guard
against an empty regex. preg_match('', '...') returns false
with a deprecation/warning in modern PHP, and admin-controlled
regex values that are syntactically invalid (or ReDoS-prone)
would error out at runtime. The OAuth path (line 771) already
has the !empty() check; this aligns the email-OTP path with it.

Use @preg_match() and explicit === 1 comparison so an invalid
pattern silently disables the check instead of leaking warnings
or being bypassed by a return value of false (which would
have logged the user out unexpectedly). Fixes H7.
Two related bugs in the send_code flow:

1. The return value of send_login_code_email() was discarded. SMTP
   failures (server misconfig, quota, network) left the user
   looking at 'check your email' forever, with no code ever
   delivered. Now we surface a generic error and reset the
   stage to 'email' so the user can retry.

2. record_attempt('send_code') ran unconditionally, even when
   create_pending_user() returned null (transient DB error, race
   lost) or the email failed. A handful of transient errors
   would lock the IP out for an hour with no email ever sent.
   Now we only count the attempt when the email was successfully
   queued (or, more precisely, when sendStyledEmail did not
   return a failure).

Fixes M5 and M6 from the second review.
…d XSS)

post_reservation_content was stored verbatim from POST and rendered
as raw HTML in every user-facing reservation page. CKEditor's
sanitization runs client-side only, so a raw POST (or a
compromised admin session) could store <script> that then
executes in every regular user viewing the affected sala's
post-reservation page.

Two layers of defense:

1. Sanitize on save in admin/salas_postreserva.php with
   HTMLPurifier. The allowlist permits the markup that
   CKEditor produces (headings, lists, tables, links with
   http/https/mailto/tel schemes only) but strips scripts,
   iframes, javascript: URLs, and event-handler attributes.
2. Re-purify on render in reservar/manage.php (3 sites).
   Defense in depth for any legacy rows that predate the
   input-side sanitization.

ezyang/htmlpurifier v4.19 is added to composer.json/lock.
The OAuth callback merged any user with a matching email (other
than the current OAuth id) into the new OAuth id, including all
reservas, logs, AND the admin flag. That meant:
  - A different IdP / changed tenant returning a different 'sub'
    for the same email silently absorbed the original account.
  - A long-standing real OAuth user could be replaced by a new
    authenticator with the same email.
  - The new account inherited the original's admin flag, so
    this was a privilege-escalation vector.

The merge now requires the source row to have a pre-registered
prefix (pre_, pending_, admin_first_). Without one of those
prefixes the row is left alone and the OAuth user is created
fresh (or co-exists, depending on INSERT IGNORE semantics).

Fixes C4 from the second review.
The OAuth flow set \[id/nome/email/admin/validity] from
the IdP response (line ~681-685) before checking whether TOTP
was required. If TOTP was required, the user was redirected to
the TOTP step while those session keys were still set.

Most pages check isset(\['pending_totp_user']) and
redirect away, but a new page (or a future page that forgets
the guard) would treat the partially-authenticated session as
fully authenticated and grant admin access. The email-OTP
path already does the unset() (matches the symmetric case
earlier in this file); this commit mirrors that in the OAuth
path.

Fixes H6 from the second review.
Add a trusted referer helper and use it before rendering back links
or deciding whether to show the admin pedidos return button. External
or malformed referers now fall back to safe local paths.

Track email-code verification failures per user by deriving a stable
namespaced action key from the user id. This prevents one user's failed
OTP attempts from consuming another user's budget on the same IP while
still fitting the existing rate_limits.action column.

Add a small PHP helper test covering trusted referer handling and the
per-user verify_code action key.
Validate the selected room before rendering reservation week links,
showing an invalid classroom error when the sala id does not exist.
Week navigation links are now built with http_build_query and escaped
before output.

Redact sensitive keys recursively from admin action POST/GET logs while
preserving non-sensitive request context for auditing.

Fix record_attempt's UPDATE bind types so the action parameter is bound
as a string, preventing auth rate-limit rows from being matched or
updated incorrectly.
Copilot AI review requested due to automatic review settings June 6, 2026 22:45
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@marpisco

marpisco commented Jun 6, 2026

Copy link
Copy Markdown
Owner Author

@codex please review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

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.

Pull request overview

This PR implements a broad set of security hardening follow-ups across the ClassLink PHP app, focusing on CSRF protection for destructive actions, XSS/HTML sanitization, OAuth/TOTP flow tightening, request log redaction, and new rate-limiting helpers for authentication.

Changes:

  • Added new security helper modules (trusted referer parsing, request data redaction, and DB-backed rate limiting) plus a small CLI-style test for them.
  • Hardened auth flows (email code validation + throttling, OTP invalidation/reset behavior, TOTP lockouts, safer OAuth error handling, local QR rendering).
  • Converted multiple destructive/admin flows from GET to POST with CSRF enforcement, and expanded output escaping/sanitization in reservation/admin UIs.

Reviewed changes

Copilot reviewed 31 out of 32 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tests/security_helpers_test.php Adds a lightweight test script covering new helper functions (trusted referer, redaction, rate-limit internals).
src/db.php Boots session hardening earlier and introduces the rate_limits table + tighter first-run detection.
reservas/index.php Switches reservation deletion UX to POST + CSRF (modal-driven).
reservar/manage.php Enforces CSRF on POST and moves reservation deletion behind POST-only handling.
reservar/index.php Adds session hardening include and strengthens output escaping in room selection/navigation.
login/index.php Adds rate limiting + validation, improves OTP/TOTP handling, renders QR locally, and hardens OAuth error handling/account merge behavior.
index.php Ensures session hardening settings are applied before session start.
func/trusted_referer.php Introduces helper to safely derive same-origin “back” paths from Referer.
func/session_config.php Minor tweak to session config file comments/usage expectations.
func/request_redaction.php Introduces recursive redaction for sensitive request keys before logging.
func/rate_limit.php Adds DB-backed per-IP rate limiting/attempt tracking utilities.
func/csrf.php Adds CSRF token regeneration for auth state changes.
composer.json Adds chillerlan/php-qrcode and ezyang/htmlpurifier dependencies.
composer.lock Locks new dependencies and updates lock metadata accordingly.
admin/users.php Moves destructive actions to POST and updates client rendering to use POST forms.
admin/tempos.php Moves destructive actions to POST (UI template updated accordingly).
admin/salas.php Moves destructive actions to POST (UI template updated accordingly).
admin/salas_postreserva.php Sanitizes post-reservation HTML before storing to DB.
admin/reservaemmassa.php Improves escaping for tempo checkbox attributes.
admin/relatorios.php Ensures session hardening include is applied.
admin/pedidos.php Moves approve/reject/bulk actions to POST handling and updates UI JS submission.
admin/materiais.php Escapes room option values/names in selects.
admin/index.php Adds session hardening include, enforces CSRF for POSTs, and redacts logged request payloads.
admin/api/users_search.php Avoids returning secrets by exposing hasTotp boolean instead of totp_secret.
admin/api/tempos_search.php Applies session hardening include.
admin/api/tempo_lookup.php Applies session hardening include.
admin/api/salas_search.php Applies session hardening include.
admin/api/sala_lookup.php Applies session hardening include.
admin/api/requisitor_lookup.php Applies session hardening include.
admin/api/recipients_preview.php Applies session hardening include.
admin/api/dashboard_stats.php Applies session hardening include.
admin/api/api_registos.php Applies session hardening include.
Comments suppressed due to low confidence (1)

admin/pedidos.php:1395

  • submitBulkAction() uses form.submit() on a dynamically-created form. This bypasses the submit event, so the CSRF token auto-injection in admin/index.php won't run and the POST will be blocked. Use requestSubmit() (with fallback) instead.
    form.appendChild(input);
    document.body.appendChild(form);
    form.submit();
}

Comment thread reservas/index.php
Comment thread reservar/manage.php
Comment thread admin/users.php
Comment thread admin/tempos.php
Comment thread admin/salas.php
Comment thread admin/pedidos.php
Comment thread admin/pedidos.php
Comment thread func/trusted_referer.php Outdated
Comment thread reservar/index.php Outdated
Comment thread admin/reservaemmassa.php
@marpisco

marpisco commented Jun 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc2abb1403

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread login/index.php Outdated
Comment thread admin/pedidos.php Outdated
Comment thread reservar/manage.php Outdated
Comment thread admin/users.php Outdated
Comment thread admin/salas.php Outdated
Comment thread admin/tempos.php Outdated
Comment thread func/rate_limit.php
Comment thread func/rate_limit.php
Comment thread func/trusted_referer.php
marpisco added 6 commits June 7, 2026 21:46
The newly-generated delete and remove-TOTP forms post action in the
request body, but the switch in users/salas/tempos dispatched only
from \['action']. POST submissions fell through to the null case
and silently no-op'd. Resolve the action from \ first, falling
back to \ so existing link-based GET flows still work, and use
the resolved value in both the 405 guard and the switch.
The delete modal posted tempo/sala/data in the body, but the
server-side handler required \['subaction'] === 'apagar' and
subaction was only in the query string. Either side could be tightened
but never both at once: the modal could include subaction in the
body, or the handler could accept it from the query string on a POST
request. We do both.

- modal: also include subaction=apagar in the POST body so the form
  is self-describing
- handler: accept subaction from POST or GET when the request method
  is POST, so the query-string form used by callers also works

The POST + CSRF guard at the top of manage.php is the real safety
property; subaction routing is convenience.
admin/pedidos.php builds three forms dynamically (per-row approve,
per-row reject, bulk action) and submitted them with form.submit().
Programmatic form.submit() does not fire the submit event, so the
global CSRF injector in admin/index.php never ran and every
dynamically-built POST was rejected with 403 by the global CSRF
guard.

Replace form.submit() with a new window.__submitFormWithCsrf helper
that uses requestSubmit() (which fires the submit event) and falls
back to manual ensureCsrf() + submit() on older browsers. Affects
the per-row approve, per-row reject, and bulk submit paths.
parse_url referer host returns just the hostname, while HTTP_HOST typically includes the port (example.test:8080). A direct string compare therefore incorrectly rejected genuinely same-origin absolute referers on non-default ports - the reservation back link then always fell back to /reservar and the manage page no longer recognized navigation from /admin/pedidos.php.

Parse both sides into host + port and compare the structured values. Add a small parse_host_and_port helper that handles IPv6 literals and credentials.
The checkbox id was HTML-escaped but the corresponding <label for=...> still received the raw tempo id. Use a single escaped value for both attributes so the association is consistent and there is no path for an injected id to break out of the attribute.
Two P1 issues with the authentication rate limiter are addressed together because they share the same code path.

1. Source IP trust: get_client_ip() previously honoured HTTP_CLIENT_IP and HTTP_X_FORWARDED_FOR on every request, so an attacker able to reach the app directly (or behind a proxy that preserves those headers) could rotate the value on each request to bypass the per-IP rate limit on send_code, verify_code and verify_totp. Both headers are now only consulted when the immediate peer (REMOTE_ADDR) is on a new trusted_proxies allowlist stored in the config table. When no proxies are configured (the default), the application always uses REMOTE_ADDR.

2. Atomic check-and-reserve: the previous check_rate_limit + record_attempt pair had a TOCTOU window: concurrent bursts could all observe a counter below the threshold and proceed; duplicate-key insert failures on the first attempt were silently swallowed, so only one attempt was actually counted for N concurrent ones. Add reserve_rate_limit_attempt() which uses SELECT ... FOR UPDATE inside a transaction to atomically decide and increment. Migrate the send_code, verify_code and verify_totp call sites. clear_attempts() is invoked when a code is reserved but never delivered (transient SMTP/DB failures), preserving the previous 'don't consume rate on SMTP failure' semantics.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

$stmt = $db->prepare("DELETE FROM reservas WHERE sala=? AND tempo=? AND data=?");
$stmt->bind_param("sss", $sala, $tempo, $data);
if (!$stmt->execute()) { http_response_code(500); die("Houve um problema a apagar a reserva. Contacte um administrador, ou tente novamente mais tarde."); }
$stmt->close();
header("Location: /reservar/?sala=" . urlencode($sala));
exit();

P2 Badge Preserve deletion audit logs and notifications

Every reservation deletion now returns immediately after this new POST-path DELETE, while the prior deletion branch that logged who removed the reservation and called sendReservationDeletedEmail() was replaced with a no-op. Consequently successful user and administrator deletions are no longer recorded in the audit log, and affected requisitors receive no deletion notification.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread func/logaction.php Outdated
Comment thread login/index.php Outdated
…rvation on SMTP failure

Two follow-up findings from the second review pass.

1. Rightmost untrusted XFF (P1): the previous trusted-proxy check returned the leftmost XFF entry, which is attacker-controlled. Walk X-Forwarded-For from the right, skip any proxies in the trusted_proxies allowlist, and return the first untrusted address. Drop the non-standard HTTP_CLIENT_IP header entirely; no real reverse proxy populates it. The pure resolver is factored into resolve_client_ip() so it can be unit-tested without globals or a database.

2. send_code reservation on SMTP failure (P2): clear_attempts('send_code') on SMTP failure deleted the entire rate-limit row, letting an attacker submit undeliverable addresses to reset the shared IP counter and exceed the 10/hour limit. The reservation now stands on SMTP failure; the user is rate-limited as if the request had succeeded, and the lockout expires in one hour. A per-reservation release would race with concurrent reservations.
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Two follow-up findings from the second review pass are addressed in 8bd0037:

  • P1 (rightmost untrusted XFF)get_client_ip() now walks X-Forwarded-For from the right, skipping any proxies in the trusted_proxies allowlist, and returns the first untrusted address. The non-standard HTTP_CLIENT_IP header is dropped entirely. The pure resolver is factored into resolve_client_ip() in func/logaction.php so it can be unit-tested.
  • P2 (send_code reservation on SMTP failure)clear_attempts('send_code') on SMTP failure used to delete the entire rate-limit row, letting an attacker submit undeliverable addresses to reset the shared counter. The reservation now stands on SMTP failure. A per-reservation release would race with concurrent reservations, so the simpler "count the attempt" semantic is the right one here.

Tests in tests/security_helpers_test.php cover single-hop, multi-hop, all-trusted-hop, all-Client-IP-only, mixed-invalid-entry, and untrusted-REMOTE_ADDR cases. Lint clean, php tests/security_helpers_test.php passes. Resolving the threads now.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8bd0037e93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread admin/users.php Outdated
Comment thread login/index.php Outdated
Comment thread func/rate_limit.php Outdated
marpisco added 3 commits June 7, 2026 22:47
…orm values

The new delete-user, delete-room, delete-time-slot and remove-TOTP forms used urlencode(\['id']) for both the query-string edit link and the form value. Browsers do not URL-decode form input values, so the server received the percent-encoded ID and compared it against the raw value stored in the database, mismatching for any ID containing URL-special characters (notably OAuth/OIDC 'sub' values used as cache.id, which can contain '/', '+' or '=').

Use the URL-encoded value for query strings (where it is required by the URL) and a separate HTML-escaped raw value for form value attributes. Affects the PHP-rendered forms in admin/users.php and admin/materiais.php, and the JS-generated forms in admin/salas.php and admin/tempos.php.
… attempts' semantics

The reserve_rate_limit_attempt helper denies when newCount > maxAttempts, so with maxAttempts=5 the 5th wrong attempt is allowed and the 6th is the one that triggers invalidation/block. The original check_rate_limit denied on attempts >= 5, so the 5th attempt was the one that triggered the lockout.

Drop maxAttempts from 5 to 4 in the verify_code, verify_totp and verify_totp_setup call sites. With the new threshold the 5th wrong attempt is the one that triggers invalidation/block, matching the documented 'after 5 wrong attempts' budget. send_code keeps maxAttempts=10 because the original used check_rate_limit strictly less-than, and the same numeric budget is preserved.
…dows

The reserve function wrote window_start via PHP's date() but read it back via MySQL's UNIX_TIMESTAMP(). The two use independent time-zone settings, so a row inserted by PHP could read back as an epoch several hours off depending on the configured offsets. The window could appear already expired (letting an attacker submit unlimited requests) or remain active hours too long (locking legitimate users out).

Use MySQL's NOW() exclusively for both the row write and the window comparison. The window filter in the SELECT is window_start >= DATE_SUB(NOW(), INTERVAL ? SECOND), evaluated by MySQL; the write is INSERT/UPDATE ... window_start = NOW(), also evaluated by MySQL. No PHP/MySQL clock mixing.

Also update the legacy record_attempt() helper to the same convention and drop the now-unused rl_record_attempt_update_bind_types() helper. record_attempt() is no longer called by production code; reserve_rate_limit_attempt() is the supported path. The test that referenced the deleted helper is removed.
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Three follow-up findings from the third review pass are addressed in three new commits:

  • 1347826 fix(security): post raw IDs (not URL-encoded) in destructive-action form values — the new delete-user, remove-TOTP, delete-room and delete-time-slot forms were passing urlencode($row['id']) as the form value, so browsers sent the percent-encoded ID in the POST body and the server compared it against the raw DB value. The bug actually only manifests for IDs with URL-special characters (notably OAuth/OIDC sub values used as cache.id, which can contain /, + or =), but the principle is general. Use $idEnc (URL-encoded) for query strings and a separate $idEsc (HTML-escaped raw) for form value attributes. Also applied to the JS-generated forms in admin/salas.php and admin/tempos.php (UUIDs in practice so less likely to manifest, but same broken pattern).
  • 685d1ee fix(security): rate-limit budgets match the documented 'after 5 wrong attempts' semanticsreserve_rate_limit_attempt denies when newCount > maxAttempts, so with maxAttempts=5 the 5th wrong attempt was allowed and the 6th triggered the lockout (one over the documented budget). The original check_rate_limit(..., 5, ...) denied on attempts >= 5, so the 5th attempt was the one that locked. Drop maxAttempts from 5 to 4 in the verify_code, verify_totp and verify_totp_setup call sites; the 5th wrong attempt is now the invalidation/block trigger, matching the documented budget. send_code keeps maxAttempts=10 because the original used check_rate_limit strictly less-than and the same numeric budget is preserved.
  • 60315a4 fix(security): use MySQL NOW() as the single clock for rate-limit windowsreserve_rate_limit_attempt previously wrote window_start via PHP's date() and read it back via MySQL's UNIX_TIMESTAMP(). The two use independent timezone settings, so the roundtrip is off by the offset, making a freshly inserted row appear already expired (or active hours too long) depending on the configured offsets. The window filter in the SELECT is now window_start >= DATE_SUB(NOW(), INTERVAL ? SECOND) and the write is ... window_start = NOW(), both evaluated by MySQL with no PHP/MySQL clock mixing. The legacy record_attempt() helper was updated the same way and the unused rl_record_attempt_update_bind_types() helper was removed.

Lint clean, php tests/security_helpers_test.php passes. Resolving the threads now.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60315a4a8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread login/index.php Outdated
Comment thread login/index.php
Comment thread login/index.php
marpisco added 2 commits June 7, 2026 23:08
…irst admin through profile setup

Two follow-up findings from the fourth review pass, addressed together because they share the same code path (the local-auth account creation flow).

1. setup_name bypassed blocked_emails_regex (P1): the setup_name handler called start_authenticated_session and redirected without applying the blocked-email policy. A user whose address matches blocked_emails_regex could request a code, verify it, and set a name to log in, bypassing the policy that applies to existing local and OAuth users in the verify_code success path. Apply the same check (skipped for admins) before redirecting to '/'.

2. First admin skipped profile setup (P2): create_pending_user() assigns the first user an admin_first_* ID (with placeholder name 'Pendente') and a pending_* ID for everyone else, but the verify_code success branch only routes pending_* to the profile-setup step. The first admin therefore logged in permanently named 'Pendente'. Treat admin_first_* like pending_* in the routing check so the first admin also goes through the name-entry step before the authenticated session starts.
…ocking

The previous pass replaced check-verify-record with reserve_rate_limit_attempt for verify_code, verify_totp and verify_totp_setup, on the theory that the rate limit should be atomic. But the limit is on wrong codes, not on attempts, and the atomic helper denied the 5th submission before verifyKey ran, so even a correct 5th code was rejected for 15 minutes. The documented 'after 5 wrong attempts' budget effectively allowed only 4 verification attempts.

Revert the three handlers to check-verify-record: a wrong code records an attempt and only invalidates/blocks if the count is now at the cap; a correct code is always accepted. The reserve helper remains the right choice for send_code, where the limit is on requests rather than on wrong outcomes. The TOCTOU window (concurrent wrong guesses from the same IP/user passing the same check) is acceptable here because the lockout applies per-user (verify_code) or per-IP (verify_totp) and the user is the only legitimate wrong-codes source for their own session.
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Three follow-up findings from the fourth review pass are addressed in two new commits:

  • 5da6620 fix(security): apply blocked-email policy to new accounts and route first admin through profile setup
    • P1: setup_name called start_authenticated_session and redirected without applying blocked_emails_regex, so a previously unknown address that matches the policy could complete the local-auth flow (request a code, verify it, set a name) and bypass the policy. Apply the same $_SESSION['admin']-exempted check that already runs in the verify_code success path before redirecting to /.
    • P2: create_pending_user() assigns the first user an admin_first_* ID and everyone else a pending_* ID, but verify_code only routed pending_* to the profile-setup step. The first admin was therefore logged in permanently named Pendente. Treat admin_first_* like pending_* in the routing check.
  • f1a9f12 fix(security): always verify the 5th TOTP/email-code attempt before locking — the previous pass replaced check_rate_limit + record_attempt with reserve_rate_limit_attempt for the three wrong-code handlers (verify_code, verify_totp, verify_totp_setup). The reserve denies the 5th submission before verifyKey/password_verify runs, so even a correct 5th code was rejected for 15 minutes. The documented "after 5 wrong attempts" budget effectively allowed only 4 verification attempts. Reverted those three handlers to check-verify-record: a wrong code records an attempt and only invalidates/blocks if the count is at the cap; a correct code is always accepted. The reserve helper remains in use for send_code, where the limit is on requests rather than on wrong outcomes.

Lint clean, php tests/security_helpers_test.php passes. Resolving the threads now.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1a9f12af6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread login/index.php
The previous fix added admin_first_* to the profile-setup routing branch in verify_code, but that branch redirects to setup before the admin TOTP check runs, and setup_name calls start_authenticated_session directly. The first administrator therefore received a fully authenticated admin session without configuring or verifying TOTP, bypassing the admin_requires_totp policy.

After name setup, check whether the user is an admin who needs TOTP. If they have no secret, redirect to totp_setup; if they have a secret, redirect to totp. This mirrors the regular verify_code TOTP enforcement at line 294.
@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 80d056a:

P1: route first admin from setup_name back into TOTP flow — the previous fix added admin_first_* to the profile-setup routing branch in verify_code, but that branch header(); exit()'d before the admin TOTP check at line 294 ran, and setup_name called start_authenticated_session directly. The first administrator therefore received a fully authenticated admin session without configuring or verifying TOTP, bypassing the admin_requires_totp policy. After name setup, setup_name now checks whether the user is an admin who needs TOTP: no secret → redirect to totp_setup, has a secret → redirect to totp. Mirrors the regular verify_code TOTP enforcement.

Lint clean, tests pass. Resolving the thread.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

$requestPath = rawurldecode($requestPath);
$requestPath = str_replace('\\', '/', $requestPath);
$requestPath = preg_replace('#/+#', '/', $requestPath);
$requestPath = '/' . ltrim($requestPath, '/');
$isAdminApiRequest = str_starts_with($requestPath, '/admin/api/');

P2 Badge Normalize dot segments before exempting API paths

When the web server resolves dot segments while preserving the original REQUEST_URI, a POST to /admin/api/../users.php is routed to the state-changing admin page but still matches this /admin/api/ prefix and skips CSRF validation. This permits CSRF from an attacker-controlled same-site origin, where the SameSite=Lax session cookie is still attached; resolve or reject ./.. segments before deciding whether to exempt the request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread func/rate_limit.php Outdated
…tempt

The UPDATE statement in record_attempt has four ? placeholders (two INTERVAL window lengths, ip, and action) but bind_param received only three values (iss binding windowSeconds, ip, action). mysqli does not throw by default, so every call to record_attempt after the INSERT path (i.e., the second wrong code and onward) silently failed with a bind-param argument-count error, never incrementing the attempts counter past 1. The verify_code, verify_totp and verify_totp_setup rate limits were completely ineffective after the first wrong attempt. Fix the bind to supply windowSeconds twice (iiss), matching the two DATE_SUB placeholders.

The reserve_rate_limit_attempt path was unaffected: all its statements have matching placeholder counts.
@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Fixed in eae9b50:

P1: bind both rate-window placeholders in record_attempt — the UPDATE statement has four ? placeholders (two INTERVAL window lengths, ip, and action) but bind_param("iss", ...) supplied only three values. mysqli does not throw by default, so every call to record_attempt after the INSERT path (the second wrong TOTP/code attempt onward) silently failed, never incrementing the counter past 1. The verify_code, verify_totp and verify_totp_setup rate limits were completely ineffective after the first wrong attempt. Fix: bind_param("iiss", $windowSeconds, $windowSeconds, $ip, $action). The reserve_rate_limit_attempt path was unaffected — all its statements have matching placeholder counts.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

$stmt = $db->prepare("DELETE FROM reservas WHERE sala=? AND tempo=? AND data=?");
$stmt->bind_param("sss", $sala, $tempo, $data);
if (!$stmt->execute()) { http_response_code(500); die("Houve um problema a apagar a reserva. Contacte um administrador, ou tente novamente mais tarde."); }
$stmt->close();
header("Location: /reservar/?sala=" . urlencode($sala));

P1 Badge Preserve deletion notifications and audit logging

When any reservation is deleted through the new POST handler, this path immediately deletes the row and redirects without calling sendReservationDeletedEmail() or logAction(). Those calls existed in the removed deletion path, so deletions initiated from the reservations modal now silently stop notifying the requisitor and disappear from the audit log.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread reservar/manage.php Outdated
Comment thread admin/users.php Outdated
Comment thread func/rate_limit.php
1. reservar/manage.php delete button was still a GET link after the POST-only handler change, so confirming the delete merely reloaded the detail page. Converted to a POST form with CSRF token and hidden subaction field.

2. admin/users.php JS-generated user cards used encodeURIComponent for form values (like the PHP-rendered forms did earlier). Browsers do not URL-decode form input values, so POSTing the percent-encoded ID mismatched the raw cache.id. Use a separate attr-safe idAttr variable with escapeHtml plus single-quote escaping.

3. verify_code rate-limit keys were per-user-per-IP because every helper called rl_get_client_ip(). An attacker could rotate IPs for the same victim and get 5 guesses from each address without invalidating the OTP. Added an optional \ parameter to record_attempt, check_rate_limit and clear_attempts, and all verify_code callers pass '0.0.0.0' so the rate limit is user-global. send_code, verify_totp and verify_totp_setup continue to use the actual IP.

Full audit of all 10 bind_param calls across the rate_limit module: all placeholder counts match their bind types (verified in eae9b50 and confirmed unchanged).
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

📝 Manual Update Required? It looks like you've modified core logic. Please check if the user manual needs a refresh! @marpisco

@marpisco

marpisco commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Three follow-up findings from the fifth review pass, addressed in a2848d9:

  • P1 — detail-page delete link still navigates with GET (reservar/manage.php:884): the delete button was still a plain <a> link, which the POST-only handler ignored. Converted to a CSRF-protected POST form with a hidden subaction=apagar field.
  • P2 — JS-generated user-card forms post percent-encoded IDs (admin/users.php:292): like the PHP-rendered forms fixed earlier, the JS-loaded search-result cards used encodeURIComponent(user.id) for form value attributes. Browsers do not URL-decode form input values, so the POST body carried the percent-encoded ID which mismatched the raw cache.id. Added a separate idAttr variable that passes through escapeHtml plus single-quote replacement, so the form posts the raw ID.
  • P1 — rate-limit email-code guesses across IPs (func/rate_limit.php): the verify_code attempt key was (ip, verify_code:<hash>), meaning the five-guess budget was per-user-per-IP. An attacker could rotate IPs for the same victim and get 5 guesses from each address without invalidating the OTP. Added an optional $clientIp parameter to record_attempt, check_rate_limit and clear_attempts; all verify_code callers now pass '0.0.0.0' so the limit is user-global. The send_code, verify_totp and verify_totp_setup paths continue using the actual IP as intended.

Full audit: all 10 bind_param calls across the rate-limit module verified against their SQL placeholder counts. The bind-param mismatch fixed in eae9b50 was the only anomaly — every other call site has matching placeholders. reserve_rate_limit_attempt (send_code path) was unaffected.

Lint clean, tests pass. Resolving the threads.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2848d9677

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread login/index.php
Comment on lines +348 to +349
record_attempt($verifyCodeAction, 3600, '0.0.0.0');
if (!check_rate_limit($verifyCodeAction, 5, 3600, '0.0.0.0')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Count globally scoped OTP failures atomically

When an attacker submits concurrent wrong codes for the same user, each request verifies the code before reaching this non-atomic record_attempt() call, whose missing-row inserts and read-then-update path can collapse many failures into one recorded attempt. The OTP therefore remains valid after far more than five guesses, allowing a later correct concurrent guess to authenticate. Fresh evidence beyond the existing atomic-rate-limit comment is that the follow-up now deliberately routes the user-global 0.0.0.0 counter through the documented TOCTOU helper instead of an atomic failed-attempt increment.

Useful? React with 👍 / 👎.

Comment thread login/index.php
Comment on lines +106 to +111
$claimId = 'first_user_claim_' . bin2hex(random_bytes(8));
$stmt = $db->prepare("INSERT INTO config (config_key, config_value) VALUES ('first_user_admin_id', ?) ON DUPLICATE KEY UPDATE config_value = config_value");
if ($stmt) {
$stmt->bind_param("s", $claimId);
$stmt->execute();
$isFirstUser = $stmt->affected_rows === 1; // 1 = inserted (we won the race), 0 = updated (someone else got there first)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Commit the first-admin claim with the user insert

On a fresh installation, this claim is committed before the subsequent cache insert and is never rolled back if that insert fails. An unauthenticated first request can therefore permanently consume the admin claim—for example, a syntactically valid email longer than the cache.email VARCHAR(99) column causes the later insert to fail under MySQL strict mode—leaving cache empty while every later account is created as a non-admin. Put the claim and user insert in one transaction and only retain the claim after the user row succeeds.

Useful? React with 👍 / 👎.

Comment thread login/index.php
Comment on lines +945 to +947
$content .= '<details style="margin-top: 1rem; text-align: left; font-size: 0.85rem;">';
$content .= '<summary style="cursor: pointer; opacity: 0.7;">Debug</summary>';
$content .= '<pre style="margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.05); border-radius: 6px; white-space: pre-wrap; word-break: break-word;">' . $debugDetails . '</pre>';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep OAuth exception details server-side

When the identity provider returns an error other than invalid_grant, this renders the raw IdentityProviderException message to the unauthenticated requester inside the Debug section. Provider exception messages can include response bodies and diagnostic details that were intended only for logs, so the new generic-error handling still exposes the sensitive value it logs at line 933; render only the generic message and keep $e->getMessage() server-side.

Useful? React with 👍 / 👎.

Comment thread login/index.php
Comment on lines +200 to +205
} elseif (!reserve_rate_limit_attempt('send_code', 10, 3600)) {
// Issue 2: rate limit send_code per IP (10/hour). The reserve
// is atomic so concurrent bursts cannot exceed the limit;
// the rejected request is also declined before we spend
// any SMTP budget.
$localAuthError = 'Demasiados pedidos. Por favor aguarde antes de tentar novamente.';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rate-limit code delivery per recipient

When an attacker can rotate source IPs, this per-IP-only reservation gives every address another ten requests and never limits requests for the same recipient. Each accepted request sends another email and overwrites that user's current OTP, so a distributed attacker can continuously email-bomb a victim and prevent their valid code from working despite the new send-code limiter. Add a recipient-scoped limit alongside the IP-scoped budget.

Useful? React with 👍 / 👎.

@marpisco marpisco marked this pull request as draft June 11, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants