Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,8 @@ of the diff ship together or not at all.
`api_comms_add` / `api_bans_add` / `api_admins_add` for the
canonical reference shape. The constant's docblock spells out the
contract: byte-for-byte symmetry with the form template's
`pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"`, the load-bearing
*rendered* `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"`
(template source writes `{17}` as `{ldelim}17{rdelim}`), the load-bearing
`D` modifier (without it `STEAM_0:0:1\n` slips past the gate and
500s on `toSteam2()`), and the deliberate asymmetry with
`ID_PATTERNS` (bracketless Steam3 `U:1:N` is excluded from the
Expand All @@ -787,10 +788,12 @@ of the diff ship together or not at all.
the pre-#1423-follow-up-#4 hand-rolled copies silently missed the
`D` modifier, producing the newline-bypass class. The client-side
native validation in the corresponding form template uses the
matching `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"` (HTML's
`pattern` attribute is implicitly anchored `^…$`, so the PHP
regex carries explicit `^…$`); the browser-native popover is the
UX-first gate that fires BEFORE the IIFE calls `sb.api.call`; the
matching `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{ldelim}17{rdelim}"`
(HTML's `pattern` attribute is implicitly anchored `^…$`, so the PHP
regex carries explicit `^…$`; `{ldelim}`/`{rdelim}` keep the `{17}`
quantifier out of Smarty's delimiter parser so a 17-digit SteamID64
actually matches). The browser-native popover is the UX-first gate
that fires BEFORE the IIFE calls `sb.api.call`; the
server-side `preg_match` is the load-bearing security gate for
curl-driven / third-party-theme callers that bypass it.

Expand Down Expand Up @@ -945,7 +948,7 @@ of the diff ship together or not at all.
`web/tests/integration/SteamIDValidationOrderTest.php` static-
shape-pins the validate-then-convert order across every page
handler. The form templates also carry the same
`pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"` +
`pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{ldelim}17{rdelim}"` +
actionable `title="…"` as the JSON-flow add-form templates so
the browser-native popover surfaces the same error message
pre-flight.
Expand Down Expand Up @@ -2011,6 +2014,19 @@ without a paired theme toggle in the wizard chrome.
`View::DELIMITERS`. `page_youraccount.tpl` was on this list before
#1123 B20 rewrote it in standard `{ }` delimiters; do NOT regress
it back to `-{ … }-` without a paired edit here.
- Regex quantifiers that use braces (`\d{17}`, `[0-9]{1,3}`, …) in a
`.tpl` file MUST use `{ldelim}17{rdelim}` (or sit inside an
existing `{literal}` JS block). Smarty treats `{17}` as a tag.
The Steam ID `pattern` attributes are the canonical site:
`pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{ldelim}17{rdelim}"`
renders as `\d{17}` in the HTML the browser validates against.
Do not open a new `{literal}` pair in an HTML attribute: a
`{literal}` mention in a `{* *}` comment above it will pair with
the closer and SmartyTemplateRule will miss every variable in
between. JS inside an existing `{literal}` block is already safe.
PHP regexes are not Smarty and stay `\d{17}`. Regression:
`SteamIDValidationOrderTest::testRenderedSteamPatternKeepsSeventeenDigitQuantifier`
+ `testTemplatesHaveNoBareDigitBraceQuantifiers`.

### Install wizard (`web/install/`)

Expand Down Expand Up @@ -4240,6 +4256,19 @@ the spec, target a 1920px viewport, not 1440px.
cannot-throw guarantee in handler code, so wrapping it in
`try/catch` is a code smell signalling the upstream gate is
missing.
- Bare `\d{17}` (or any `{<digits>}` regex quantifier) in a Smarty
`.tpl` `pattern` attribute without `{ldelim}`/`{rdelim}` →
Smarty treats `{17}` as a tag and the browser receives `\d17`
(or drops the quantifier). A valid 17-digit SteamID64 then fails
native HTML validation while STEAM_0:1:N and `[U:1:N]` still
pass (`\d+` has no braces). Write `\d{ldelim}17{rdelim}`. Do not
wrap the arm in a new `{literal}` pair inside the attribute: an
unmatched `{literal}` in a `{* *}` comment above it pairs with
the closer and SmartyTemplateRule reports every View property
between the two as unused. PHP regexes and JS inside an existing
`{literal}` block are fine. Regression:
`SteamIDValidationOrderTest::testRenderedSteamPatternKeepsSeventeenDigitQuantifier`
+ `testTemplatesHaveNoBareDigitBraceQuantifiers`.
- Hand-rolling the strict SteamID-shape regex literal at the
per-handler `preg_match` call site (the pre-#1423-follow-up-#4
shape: `preg_match('/^(?:STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17})$/', $raw)`
Expand Down
12 changes: 8 additions & 4 deletions web/includes/SteamID/SteamID.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,14 @@ private static function to($format, $steamid)
*
* The shape is TIGHTER than `ID_PATTERNS` on one axis: the bracketless
* Steam3 form (`U:1:N`) is INTENTIONALLY excluded so the gate matches
* the form template's `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"`
* byte-for-byte. Curl-driven callers get the same shape contract a
* form user sees on the pattern-mismatch popover; bracketless Steam3
* shape stays a library-side convenience for the conversion path
* the form template's rendered `pattern` (Steam2 / bracketed Steam3 /
* 17-digit Steam64). Template source writes the `{17}` quantifier
* as `{ldelim}17{rdelim}` so Smarty does not eat the braces; the
* HTML that reaches the browser is byte-for-byte
* `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"`. Curl-driven
* callers get the same shape contract a form user sees on the
* pattern-mismatch popover; bracketless Steam3 shape stays a
* library-side convenience for the conversion path
* (`SteamID::toSteam2('U:1:1')` still works) but isn't an accepted
* panel-input shape.
*
Expand Down
39 changes: 39 additions & 0 deletions web/tests/e2e/specs/flows/comms-add-steamid-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ import { test, expect } from '../../fixtures/auth.ts';
import { truncateE2eDb } from '../../fixtures/db.ts';

const VALID_STEAM = 'STEAM_0:1:14202020';
const VALID_STEAM64 = '76561198179807307';
const INVALID_STEAM = 'asdf';
const TARGET_NICK = 'e2e-1420-validation';
const EXPECTED_STEAM_PATTERN = 'STEAM_[01]:[01]:\\d+|\\[U:1:\\d+\\]|\\d{17}';

// `.serial` because every test in this describe runs `truncateE2eDb()`
// in `beforeEach`, and a sibling test's API call landing during another
Expand Down Expand Up @@ -368,4 +370,41 @@ test.describe.serial('flow: comms-add SteamID validation feedback (#1420)', () =
await expect(inlineErr).toBeVisible();
await expect(inlineErr).toContainText(/valid Steam ID|Community ID/i);
});

// Lives in this `.serial` group (not a sibling `describe`) so a
// local `workers: <cores>` run cannot `page.goto` the form while
// another test in this file is inside `truncateE2eDb()`. CI pins
// `workers: 1` and would not see that flake.
test('17-digit SteamID64 passes native validation on add-block / add-ban / submit', async ({
page,
}) => {
const surfaces: Array<{ url: string; testId: string }> = [
{ url: '/index.php?p=admin&c=comms', testId: 'addcomm-steam' },
{ url: '/index.php?p=admin&c=bans&section=add-ban', testId: 'addban-steam' },
{ url: '/index.php?p=submit', testId: 'submitban-steam' },
];

for (const surface of surfaces) {
await page.goto(surface.url);
const steam = page.getByTestId(surface.testId);
await expect(steam).toBeVisible();
await steam.fill(VALID_STEAM64);

const validity = await steam.evaluate((el: HTMLInputElement) => ({
pattern: el.getAttribute('pattern'),
valid: el.validity.valid,
patternMismatch: el.validity.patternMismatch,
}));

expect(
validity.pattern,
`${surface.testId} rendered pattern must keep the {17} quantifier`,
).toBe(EXPECTED_STEAM_PATTERN);
expect(
validity.patternMismatch,
`${surface.testId}: ${VALID_STEAM64} must not patternMismatch`,
).toBe(false);
expect(validity.valid, `${surface.testId}: ${VALID_STEAM64} must be valid`).toBe(true);
}
});
});
Loading
Loading