SEC2-001: The share form confirms whether an email address or username has an account, and maps an address to its username, unthrottled #943

Closed
opened 2026-10-17 10:37:17 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Low · Category: Data Exposure · Reference: CWE-204 (Observable Response Discrepancy); CWE-770 (Allocation of Resources Without Limits); OWASP A01:2021 (Broken Access Control) / A07:2021 (Identification and Authentication Failures)

Location:

  • src/postulo/jobs/sharing.py:262-284 (grantee_from)
  • src/postulo/jobs/share_views.py:45-83 (ShareView.post)

Issue

Any signed-in member who owns one company, contact or listing can open its Sharing page and type a value into With a person. The lookup resolves it against every active account by username or email address:

# jobs/sharing.py
    found = User.objects.filter(is_active=True).filter(
        Q(username__iexact=value) | Q(email__iexact=value)
    )
    person = found.first() if found.count() == 1 else None

The view answers differently for a hit and a miss, and names the account on a hit:

# jobs/share_views.py
        if grantee is None:
            messages.error(request, _("Nobody on this instance answers to that, or it is you; nothing was shared."))
            ...
        messages.success(request, _("Shared with %(who)s.") % {"who": sharing.grantee_label(grantee)})

grantee_label returns grantee.get_username(), and the grants list on the same page prints grant.grantee_user.get_username for each share. So typing an email address tells the sharer (a) that an account with that address exists on this instance and (b) which username it belongs to. Nothing throttles the view: neither share_views.py nor sharing.py uses postulo.core.throttle. The template's own comment says "an instance's accounts are not a directory to pick from", but the lookup behaves as a directory for anybody who types.

#745 fixed the same class of oracle on the sign-up form and Settings → Account by bounding username attempts to POSTULO_USERNAME_RATE (10/h). The sharing feature (#896), landed after that audit, reintroduced an unbounded one that also covers email addresses, which the earlier surfaces never answered for. The refusal wording is already identical for an unknown address, a deactivated account's address and the sharer's own address (share_views.py:52); the gap is the hit, and the volume.

Impact

A signed-in member (the lowest-privileged account on a shared instance) can test a list of email addresses against the instance and learn which colleagues, family members or strangers hold an account here and under which username; a recruiter's or a competitor's address can be confirmed. It also hands them the username needed for the per-account sign-in lock of #489/#744 and for targeted sharing. Each hit creates a share, which is undone only by revoking, so a probe would use read-only scope and revoke afterwards.

Fix

  1. In ShareView.post, consume a per-account throttle before resolving the grantee, as AccountForm.clean_username does, keyed on request.user and spent only when value is non-empty and does not start with group: (choosing one's own group never costs allowance, as AccountForm exempts one's own name):
    from postulo.core import throttle
    try:
        throttle.consume("share-lookup", request.user, throttle.rate_for("POSTULO_SHARE_LOOKUP_RATE"))
    except throttle.TooOften as too_often:
        messages.error(request, str(too_often)); return redirect(request.path)
    
    with a new POSTULO_SHARE_LOOKUP_RATE (default 20/h, in line with POSTULO_NUMBER_RATE) in config/settings/base.py beside POSTULO_USERNAME_RATE.
  2. Stop disclosing the address-to-username mapping. A hit must say whom it shared with, so there are two ways: resolve usernames only (the smaller change; tests/test_share_flow.py:56-60, which shares by address, changes with it), or keep addresses and show the typed address rather than the username on a hit and in the grants list.
  3. Done when a test in tests/security/test_rate_limits.py posts N+1 distinct lookups to jobs:share and sees the last refused, and a test asserts the refusal wording is identical for an unknown address, a deactivated account's address and the sharer's own address.

#745 (closed) - the same enumeration class on the sign-up form and Settings → Account, fixed there by a throttle; this is a new, unthrottled surface that also resolves email addresses and reveals the username, so an incomplete fix rather than a duplicate. #744 (closed) - the per-account sign-in lock the username feeds. #896 (closed) - the feature itself; its body does not discuss enumeration. #268 (open) - background only; its Part 3 is a design discussion and does not cover enumeration.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Data Exposure · **Reference:** CWE-204 (Observable Response Discrepancy); CWE-770 (Allocation of Resources Without Limits); OWASP A01:2021 (Broken Access Control) / A07:2021 (Identification and Authentication Failures) **Location:** - `src/postulo/jobs/sharing.py:262-284` (`grantee_from`) - `src/postulo/jobs/share_views.py:45-83` (`ShareView.post`) ## Issue Any signed-in member who owns one company, contact or listing can open its *Sharing* page and type a value into *With a person*. The lookup resolves it against every active account by username **or email address**: ```python # jobs/sharing.py found = User.objects.filter(is_active=True).filter( Q(username__iexact=value) | Q(email__iexact=value) ) person = found.first() if found.count() == 1 else None ``` The view answers differently for a hit and a miss, and names the account on a hit: ```python # jobs/share_views.py if grantee is None: messages.error(request, _("Nobody on this instance answers to that, or it is you; nothing was shared.")) ... messages.success(request, _("Shared with %(who)s.") % {"who": sharing.grantee_label(grantee)}) ``` `grantee_label` returns `grantee.get_username()`, and the grants list on the same page prints `grant.grantee_user.get_username` for each share. So typing an email address tells the sharer (a) that an account with that address exists on this instance and (b) which username it belongs to. Nothing throttles the view: neither `share_views.py` nor `sharing.py` uses `postulo.core.throttle`. The template's own comment says "an instance's accounts are not a directory to pick from", but the lookup behaves as a directory for anybody who types. #745 fixed the same class of oracle on the sign-up form and *Settings → Account* by bounding username attempts to `POSTULO_USERNAME_RATE` (10/h). The sharing feature (#896), landed after that audit, reintroduced an unbounded one that also covers email addresses, which the earlier surfaces never answered for. The refusal wording is already identical for an unknown address, a deactivated account's address and the sharer's own address (`share_views.py:52`); the gap is the hit, and the volume. ## Impact A signed-in member (the lowest-privileged account on a shared instance) can test a list of email addresses against the instance and learn which colleagues, family members or strangers hold an account here and under which username; a recruiter's or a competitor's address can be confirmed. It also hands them the username needed for the per-account sign-in lock of #489/#744 and for targeted sharing. Each hit creates a share, which is undone only by revoking, so a probe would use read-only scope and revoke afterwards. ## Fix 1. In `ShareView.post`, consume a per-account throttle before resolving the grantee, as `AccountForm.clean_username` does, keyed on `request.user` and spent only when `value` is non-empty and does not start with `group:` (choosing one's own group never costs allowance, as `AccountForm` exempts one's own name): ```python from postulo.core import throttle try: throttle.consume("share-lookup", request.user, throttle.rate_for("POSTULO_SHARE_LOOKUP_RATE")) except throttle.TooOften as too_often: messages.error(request, str(too_often)); return redirect(request.path) ``` with a new `POSTULO_SHARE_LOOKUP_RATE` (default `20/h`, in line with `POSTULO_NUMBER_RATE`) in `config/settings/base.py` beside `POSTULO_USERNAME_RATE`. 2. Stop disclosing the address-to-username mapping. A hit must say whom it shared with, so there are two ways: resolve usernames only (the smaller change; `tests/test_share_flow.py:56-60`, which shares by address, changes with it), or keep addresses and show the typed address rather than the username on a hit and in the grants list. 3. Done when a test in `tests/security/test_rate_limits.py` posts N+1 distinct lookups to `jobs:share` and sees the last refused, and a test asserts the refusal wording is identical for an unknown address, a deactivated account's address and the sharer's own address. ## Related #745 (closed) - the same enumeration class on the sign-up form and Settings → Account, fixed there by a throttle; this is a new, unthrottled surface that also resolves email addresses and reveals the username, so an incomplete fix rather than a duplicate. #744 (closed) - the per-account sign-in lock the username feeds. #896 (closed) - the feature itself; its body does not discuss enumeration. #268 (open) - background only; its Part 3 is a design discussion and does not cover enumeration.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:37:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#943
No description provided.