Problem/Motivation

Sorry. After updating from 5.1.1 to 5.2.0 and enabling the new per-flag flood control (#3112986: Use flood control API in flag), anonymous users with the flag <flag_id> permission can no longer flag. The flag link is no longer clickable or usable for them. This still happens in 5.2.x and 5.3.x. Authenticated users (the more typical case) are not affected.

Since #3112986: Use flood control API in flag, Flag::actionAccess() combines the flag type access with FlagFloodControlService::access() for the flag action. When that returns forbidden, ActionLinkTypeBase::getAsFlagLink() renders no usable link.

For anonymous users, the flood identifier is built from the IP address only:

'anonymous:' . $request->getClientIp() . '-' . $flag_id

This leads to the following problems:

  1. All anonymous visitors can share a single flood bucket. Behind a reverse proxy, load balancer, CDN or container network without a matching reverse_proxy configuration, getClientIp() returns the proxy IP for every visitor. With the default limit (10 per 3600 s), ten anonymous flaggings on the whole site block anonymous flagging for everyone for an hour. Visitors behind a shared NAT (offices, schools, mobile carriers) run into the same problem.
  2. The denied state can be cached. The forbidden access result sets max-age 0, but core's Internal Page Cache ignores max-age for anonymous responses. A page rendered while the limit was reached can keep serving the non-flaggable state after the window ends.

The result is that anonymous flagging, which worked in 5.1.1, becomes effectively unusable once flood control is enabled on a flag.

Steps to reproduce

  1. Install Flag 5.2.0 or later and create a node flag with link type [AJAX link / Reload / Confirm form — fill in].
  2. Grant Anonymous user the flag <flag_id> and unflag <flag_id> permissions.
  3. On the flag's edit form, enable flood control with the default limit (10) and window (3600 s).
  4. Serve the site behind a reverse proxy without $settings['reverse_proxy'] configured, or use a setup where all requests come from the same IP, as in many local Docker/DDEV setups.
  5. As different anonymous visitors, flag 10 different nodes.
  6. As any anonymous visitor, view another node: the flag can no longer be used. ❌
  7. Check: SELECT identifier, COUNT(*) FROM flood WHERE event = 'flag.flagging' GROUP BY identifier; shows one shared anonymous:<ip>-<flag_id> identifier.

Expected: anonymous visitors are rate-limited individually, as far as possible, and the limit for one visitor does not lock out all others or get cached in pages.

Actual: all anonymous visitors are blocked after a few flaggings across the whole site.

Workarounds

  • Disable flood control on flags that anonymous users can use, or
  • configure reverse_proxy / reverse_proxy_addresses in settings.php correctly. This only helps with the proxy case, not with shared NAT.

Proposed resolution

To be discussed. Options:

  • Identify anonymous users by session ID, which Flag already uses for anonymous flaggings, and fall back to IP only when there is no session. Alternatively, combine session and IP.
  • Do not hide or disable the link for anonymous users based on flood state at render time. Check flood control only when the flag action is submitted and show a message there. This also removes the page cache problem and the extra ip cache context on every flag link.
  • Document clearly in the flood control settings that anonymous users are identified by IP and that a correct reverse proxy configuration is required.
  • Add functional tests: several anonymous sessions from the same IP, and the page cache behavior after the limit is reached.

Remaining tasks

  • Agree on the approach
  • Write a failing test
  • Write a fix (MR)
  • Review and commit

User interface changes

Possibly a clarified description for the flood control settings on the flag form.

API changes

Possibly: FlagFloodControlService::createIdentifier() uses a different identifier for anonymous users.

Data model changes

None.

Issue summary prepared with the help of AI!

Issue fork flag-3638001

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

anybody created an issue. See original summary.

anybody’s picture

Sorry I missed that and tests don't seem to fully cover it. I'll now try to reprocue it in a failing test and prepare a fix!

anybody’s picture

Seems I found the root cause: FlagFloodControlService::createIdentifier() identifies anonymous users only by IP ('anonymous:' . getClientIp()). Behind a reverse proxy, load balancer, container network or shared NAT, every anonymous visitor therefore shares a single flood bucket.

The flood check is also part of render-time access (Flag::actionAccess('flag') → andIf() flood access). Once that shared bucket reaches the limit (default 10/hour), ActionLinkTypeBase::getAsFlagLink() renders nothing for all anonymous users. With a rolling window, busy sites lose anonymous flagging almost permanently.

Reproduced with a new functional test: after anonymous visitor A reaches the limit, a different anonymous visitor B (new session, same IP) gets no flag link. The flood table shows one shared identifier anonymous:172.19.0.5-<flag_id>. Rendering and page caching are fine; the problem is only how anonymous users are identified.

Proposed fix: identify anonymous users by Flag's existing anonymous session ID (flag.session_id), and keep an IP-based limit only as a higher safety net against cookie-dropping.

anybody’s picture

anybody’s picture

Issue summary: View changes
anybody’s picture

Issue summary: View changes
anybody’s picture

Status: Active » Needs review

Pushed a fix to both issue branches (5.3.x and 5.2.x), all tests green.

Changes

  • Anonymous users are now counted for flood control per flag session (flag.session_id) instead of per IP, so visitors sharing an IP (reverse proxy, containers, NAT) no longer block each other.
  • New per-flag setting "Number of anonymous flaggings per IP address and time window" (flood_control_ip_limit, default 50, 0 = off). Applies to anonymous users only, so deleting the session cookie doesn't get around flood control. Must be 0 or ≥ the per-user limit.
  • When an anonymous user is denied, the page cache kill switch is triggered. The internal page cache ignores max-age 0, so otherwise a denied page would be served to all anonymous visitors.
  • flag_update_10302() sets the IP limit on existing flags to max(50, flood_control_limit).

Session behavior: viewing a page still doesn't start a session, and pages stay cacheable (page cache HIT verified). A session starts only at the first flagging. That flagging is counted even though Drupal starts the session lazily in that request.

Tests

  • New AnonymousFlagFloodTest (functional, with page cache): one visitor reaching the limit doesn't block others, no session on view, page cache hit, session lifecycle, IP limit, and the denied state isn't cached.
  • FlagFloodControlTest (kernel): session separation, IP limit, IP limit ignored for authenticated users, defaults. FlagFloodTest: validation of the new setting.
  • Checked against the bugs: putting back the IP-only identifier, ignoring the lazy session, or removing the kill switch each makes at least one of these tests fail.

API / BC: no breaking changes under the BC policy.

  • New FlagInterface::getFloodControlIpLimit() / setFloodControlIpLimit(). The interface has one implementation, like the flood methods added in 5.2.0.
  • FlagFloodControlService gets a new autowired constructor argument; service constructors are internal.
  • createIdentifier() returns a session-based value for anonymous users.

Since this adds a setting and an update hook, a minor release may be the better fit; maintainers' call. Release note: anonymous flood control is now per session plus per IP; run drush updb and export config.

Please review.

anybody’s picture

Ok we should add additional tests for views, it doesn't work there yet. In full display it works as expected. Also combined with the new captcha solution!

anybody’s picture

Status: Needs review » Postponed

Okay evantually this is false alarm and was caused by a template override in our project. I'll investigate deeper!

anybody’s picture

Priority: Major » Normal
Status: Postponed » Closed (works as designed)
Related issues: +#3638056: Page cache keeps flag links hidden for all anonymous users after one hits the flood control limit

Closing this one: the reported symptom was not caused by Flag.

  • Non-clickable links: the cause was a theme template (flag--likes--card.html.twig) that unlinks the flag. In 5.1.1 that template was never used, because the view mode wasn't passed to flag links. #3626500: [regression] Restore the view mode of flag links restored that in 5.2.0, so view-mode template suggestions now take effect. Themes with view-mode flag templates should check them when updating to 5.2.x.
  • Anonymous users sharing one flood bucket per IP: this is by design and matches core's flood control. Sites behind a proxy need a correct reverse_proxy configuration.

While debugging, I found a real, separate bug: a denied anonymous page can stay in the Internal Page Cache and hide flag links from all anonymous users. Follow-up: https://www.drupal.org/project/flag/issues/3638056. The MRs here can be closed.

Sorry for the trouble!

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.