Skip to content

Commit bb7cfc7

Browse files
IAmJSDclaude
andauthored
fix(clear-rate-limit): match RATE_LIMIT_DOMAIN literally in the LIKE pattern (#1272)
clear-rate-limit built its DELETE pattern by concatenating RATE_LIMIT_DOMAIN into "EMAIL#%@<domain>#ENDPOINT#forgot_password". The query was parameterized, but % and _ inside the domain (and the _ in "forgot_password") are LIKE wildcards, so RATE_LIMIT_DOMAIN=% cleared the forgot_password rate limits of every domain, and a domain with an _ matched any character in that position. The domain and endpoint are now escaped and the DELETE uses ESCAPE '\', so only the email's local part is a wildcard. The query moved into clearForgotPasswordLimits so a real-Postgres test can call it. The cleanup-lambda half of the same review finding is not changed here: its status guard already landed in #1173, and its retention rule is left for the maintainer to decide (see the PR body). Refs #357 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cdba8ce commit bb7cfc7

3 files changed

Lines changed: 168 additions & 4 deletions

File tree

‎cmd/lambda/clear-rate-limit/main.go‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,31 @@ import (
55
"fmt"
66
"log"
77
"os"
8+
"strings"
89

910
"github.com/LeanerCloud/cloud-commitments-platform/internal/database"
1011
"github.com/aws/aws-lambda-go/lambda"
1112
)
1213

1314
const defaultDomain = "leanercloud.com"
1415

16+
// forgotPasswordEndpoint is the rate-limit endpoint whose per-email rows this
17+
// function clears (see internal/api/rate_limiter.go).
18+
const forgotPasswordEndpoint = "forgot_password"
19+
20+
// likeEscaper escapes the LIKE metacharacters (and the escape character
21+
// itself) so a value matches only itself inside a pattern that is used with
22+
// ESCAPE '\'.
23+
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
24+
25+
// forgotPasswordPattern is the LIKE pattern for every forgot_password row of
26+
// one email domain. Only the email's local part is a wildcard: the domain and
27+
// endpoint are escaped, so RATE_LIMIT_DOMAIN=% matches a literal "%" domain
28+
// instead of every domain, and an "_" matches only "_" (issue #357).
29+
func forgotPasswordPattern(domain string) string {
30+
return "EMAIL#%@" + likeEscaper.Replace(domain) + "#ENDPOINT#" + likeEscaper.Replace(forgotPasswordEndpoint)
31+
}
32+
1533
func getDomain() string {
1634
if domain := os.Getenv("RATE_LIMIT_DOMAIN"); domain != "" {
1735
return domain
@@ -35,11 +53,15 @@ func clearRateLimit(ctx context.Context) (Response, error) {
3553
}
3654
defer db.Close()
3755

38-
// Clear rate limits for forgot_password endpoint
39-
domain := getDomain()
56+
return clearForgotPasswordLimits(ctx, db, getDomain())
57+
}
58+
59+
// clearForgotPasswordLimits deletes the forgot_password rate-limit rows of
60+
// every email at domain and reports how many rate-limit rows remain.
61+
func clearForgotPasswordLimits(ctx context.Context, db *database.Connection, domain string) (Response, error) {
4062
tag, err := db.Exec(ctx,
41-
"DELETE FROM rate_limits WHERE id LIKE $1",
42-
"EMAIL#%@"+domain+"#ENDPOINT#forgot_password")
63+
`DELETE FROM rate_limits WHERE id LIKE $1 ESCAPE '\'`,
64+
forgotPasswordPattern(domain))
4365
if err != nil {
4466
return Response{}, fmt.Errorf("failed to delete rate limits: %w", err)
4567
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//go:build integration
2+
3+
package main
4+
5+
// Real PostgreSQL tests for the clear-rate-limit DELETE (issue #357). The
6+
// migrated rate_limits table and the DELETE are real; nothing is deployed and
7+
// no Lambda is invoked.
8+
9+
import (
10+
"context"
11+
"path/filepath"
12+
"runtime"
13+
"sort"
14+
"testing"
15+
16+
"github.com/LeanerCloud/cloud-commitments-platform/internal/database"
17+
"github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations"
18+
"github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
// rateLimitRows covers the domain the tests clear, other domains, a literal
23+
// "%" domain, and rows of other endpoints and key kinds. The exampleQtest.com
24+
// and forgotXpassword rows match only while "_" is a wildcard.
25+
var rateLimitRows = []string{
26+
"EMAIL#alice@example.com#ENDPOINT#forgot_password",
27+
"EMAIL#bob@example.com#ENDPOINT#forgot_password",
28+
"EMAIL#dave@other.org#ENDPOINT#forgot_password",
29+
"EMAIL#erin@%#ENDPOINT#forgot_password",
30+
"EMAIL#frank@example_test.com#ENDPOINT#forgot_password",
31+
"EMAIL#grace@exampleQtest.com#ENDPOINT#forgot_password",
32+
"EMAIL#alice@example.com#ENDPOINT#forgotXpassword",
33+
"EMAIL#alice@example.com#ENDPOINT#login",
34+
"IP#192.0.2.1#ENDPOINT#forgot_password_ip",
35+
}
36+
37+
func rateLimitTestDB(ctx context.Context, t *testing.T) *database.Connection {
38+
t.Helper()
39+
c := testhelpers.RequirePostgresContainer(ctx, t)
40+
t.Cleanup(func() { _ = c.Cleanup(context.Background()) })
41+
_, file, _, _ := runtime.Caller(0)
42+
path := filepath.Join(filepath.Dir(file), "..", "..", "..", "internal", "database", "postgres", "migrations")
43+
require.NoError(t, migrations.RunMigrations(ctx, c.DB.Pool(), path, "", ""))
44+
return c.DB
45+
}
46+
47+
func seedRateLimits(ctx context.Context, t *testing.T, db *database.Connection) {
48+
t.Helper()
49+
_, err := db.Exec(ctx, `DELETE FROM rate_limits`)
50+
require.NoError(t, err)
51+
for _, id := range rateLimitRows {
52+
_, err := db.Exec(ctx, `INSERT INTO rate_limits (id, count, reset_time) VALUES ($1, 1, NOW() + INTERVAL '1 hour')`, id)
53+
require.NoError(t, err, "insert %s", id)
54+
}
55+
}
56+
57+
func remainingRateLimits(ctx context.Context, t *testing.T, db *database.Connection) []string {
58+
t.Helper()
59+
rows, err := db.Query(ctx, `SELECT id FROM rate_limits`)
60+
require.NoError(t, err)
61+
defer rows.Close()
62+
var out []string
63+
for rows.Next() {
64+
var id string
65+
require.NoError(t, rows.Scan(&id))
66+
out = append(out, id)
67+
}
68+
require.NoError(t, rows.Err())
69+
sort.Strings(out)
70+
return out
71+
}
72+
73+
// without returns rateLimitRows minus the given ids, sorted.
74+
func without(deleted ...string) []string {
75+
gone := map[string]bool{}
76+
for _, id := range deleted {
77+
gone[id] = true
78+
}
79+
var out []string
80+
for _, id := range rateLimitRows {
81+
if !gone[id] {
82+
out = append(out, id)
83+
}
84+
}
85+
sort.Strings(out)
86+
return out
87+
}
88+
89+
// TestClearForgotPasswordLimitsDeletesOnlyTheNamedDomain is the issue's
90+
// failure scenario: RATE_LIMIT_DOMAIN=% used to clear the forgot_password rows
91+
// of every domain, and an "_" in the domain matched any one character.
92+
func TestClearForgotPasswordLimitsDeletesOnlyTheNamedDomain(t *testing.T) {
93+
ctx := context.Background()
94+
db := rateLimitTestDB(ctx, t)
95+
96+
cases := []struct {
97+
domain string
98+
deleted []string
99+
}{
100+
{"example.com", []string{
101+
"EMAIL#alice@example.com#ENDPOINT#forgot_password",
102+
"EMAIL#bob@example.com#ENDPOINT#forgot_password",
103+
}},
104+
{"%", []string{"EMAIL#erin@%#ENDPOINT#forgot_password"}},
105+
{"example_test.com", []string{"EMAIL#frank@example_test.com#ENDPOINT#forgot_password"}},
106+
{"nobody.invalid", nil},
107+
}
108+
for _, tc := range cases {
109+
t.Run(tc.domain, func(t *testing.T) {
110+
seedRateLimits(ctx, t, db)
111+
resp, err := clearForgotPasswordLimits(ctx, db, tc.domain)
112+
require.NoError(t, err)
113+
want := without(tc.deleted...)
114+
require.Equal(t, want, remainingRateLimits(ctx, t, db))
115+
require.Equal(t, len(tc.deleted), resp.DeletedCount)
116+
require.Equal(t, len(want), resp.RemainingCount)
117+
})
118+
}
119+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// TestForgotPasswordPatternEscapesLikeMetacharacters pins the pattern the
10+
// DELETE runs with ESCAPE '\': only the email's local part is a wildcard
11+
// (issue #357). The real-Postgres test in main_integration_test.go checks
12+
// which rows the pattern actually deletes.
13+
func TestForgotPasswordPatternEscapesLikeMetacharacters(t *testing.T) {
14+
cases := map[string]string{
15+
"example.com": `EMAIL#%@example.com#ENDPOINT#forgot\_password`,
16+
"%": `EMAIL#%@\%#ENDPOINT#forgot\_password`,
17+
"example_test.com": `EMAIL#%@example\_test.com#ENDPOINT#forgot\_password`,
18+
`ex\ample.com`: `EMAIL#%@ex\\ample.com#ENDPOINT#forgot\_password`,
19+
}
20+
for domain, want := range cases {
21+
assert.Equal(t, want, forgotPasswordPattern(domain), "domain %q", domain)
22+
}
23+
}

0 commit comments

Comments
 (0)