Skip to content

feat: sms: scheme - #454

Open
maxfenton wants to merge 7 commits into
ezyang:masterfrom
maxfenton:mf/374-sms-links
Open

feat: sms: scheme#454
maxfenton wants to merge 7 commits into
ezyang:masterfrom
maxfenton:mf/374-sms-links

Conversation

@maxfenton

Copy link
Copy Markdown

Ref: #374
Ref: https://www.rfc-editor.org/rfc/rfc5724.html

Adds support for sms: scheme URLs with examples:

  • sms:555
  • sms:555?body=HOME (part of the spec)
  • sms:555&body=HOME (commonly used across the web)

@maxfenton
maxfenton force-pushed the mf/374-sms-links branch 3 times, most recently from d9c96d8 to e32b1c8 Compare September 22, 2025 16:03
@maxfenton

Copy link
Copy Markdown
Author

Can anyone help with this?

@maxfenton maxfenton changed the title [#375] sms: scheme feat: sms: scheme Nov 24, 2025
@maxfenton

Copy link
Copy Markdown
Author

Back again to try to get this approved. Fixed a bug on my side that I didn't see when the ci PHP 8.5 was failing. Please add sms support to htmlpurifier

maxfenton and others added 3 commits August 11, 2026 23:58
The previous sanitizeBody approach (strip <>"' then strip script/alert/javascript)
left residual content after multi-pass stripping. For example:
  <script>alert("xss")</script>
  → after step 1: scriptalert(xss)/script
  → after step 2: (xss)/            ← fails test expectation of empty string

Decode URL encoding first to catch encoded payloads, then reject the entire
body value if angle brackets are present (the primary HTML injection vector),
rather than attempting partial character stripping that can be bypassed.

Also re-encode the output so decoded bodies (e.g. Hello%20World) round-trip
correctly through the URL attribute context.

Add missing tests for:
- sms:988 (no body — short code used by Crisis Text Line / 988 Lifeline)
- sms:741741?body=SEIZE (RFC 5724 ?body= input normalised to &body= output)
- sms:741741&body=SEIZE (short code with body round-trip)

@ezyang ezyang left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

As Muse Code (powered by Meta Muse Spark) - automated review, not ezyang:

Request changes before merge (otherwise ready):

This adds useful sms: per RFC 5724 + real-world &body= (issue #374), phone + normalization and <> body rejection are correct, and CI was 12/12 on old base. After rebase onto 7641f75 (484+487) the PR is now MERGEABLE f65aba1, but 3 nits should be fixed:

  1. URIParser.php:53 special-case - if(scheme==sms && &body=) path->query exists only to make URIParserTest: sms:5555&body=HOME => query body=HOME pass. URIScheme/sms.php:30 already parses &body= from path and ?body= from query. This bakes scheme knowledge into the generic RFC 3986 parser. Please remove the URIParser branch and update the test to expect path '5555&body=HOME' query null (let sms.php normalize).

  2. &body= vs ?body= output - sms.php:85 always emits path &body= (query=null) while RFC is ?body=. Tests assert &body= (e.g. sms:741741?body=SEIZE -> sms:741741&body=SEIZE). Either normalize to ?body= per spec or document the intentional &body= divergence and make sms.php:47 and parser consistent.

  3. schema.ser blob - commit 34ea6..82b61 is a generated artifact. Please regenerate via maintenance/generate-schema.php rather than hand-committing URI.AllowedSchemes.txt + blob, and verify the only delta is sms=>true.

Fix those (~15 lines) and this is approve/merge. As-landed it works but leaves sms knowledge in URIParser unnecessarily.

This review was generated by Muse Code; please address comments and re-request review.

@maxfenton
maxfenton requested a review from ezyang August 13, 2026 15:38
Drop the sms special-case from HTMLPurifier_URIParser so the RFC 3986 parser stays scheme-agnostic. Splitting a body out of an sms URI is HTMLPurifier_URIScheme_sms's job; the parser branch existed only to satisfy a test expectation.

Both body forms now round-trip as written rather than being normalized into one another. RFC 3986 treats only "?" as the query delimiter, so a "&body=" arrives in the path and a "?body=" arrives in the query, and the scheme re-emits it from wherever it came in. A URI carrying both resolves to the query value, the spec form.

- remove the sms branch from URIParser and update URIParserTest to what the grammar actually produces
- revert the URISchemeTest harness change that ran expected URIs through the validator, which weakened every scheme's assertions to mask the parser mismatch
- dedupe body parsing into extractBody(); the path branch was last-body-wins and the query branch first-body-wins, both are first-wins now
- cover the ?body= form in AttrDef/URITest and MakeAbsoluteTest alongside the existing &body= cases
Two ways a message was silently dropped. The field name was matched case-sensitively, so sms:5555?BODY=HOME lost the body even though RFC 5234 makes the RFC 5724 literal case-insensitive; match it in any case and always emit it lower-case. sanitizeBody() then rejected the whole body when it decoded to contain "<" or ">" and deleted any quote, so sms:911?body=I%20%3C3%20you lost the message and He said "hi" became He said hi.

Neither control was load-bearing. rawurlencode() already percent-encodes <>'" so the value cannot terminate the attribute or open a tag, and HTMLPurifier_Generator::escape() escapes the attribute on top of that; mailto, which also takes ?body=, does no body sanitization at all. Reduce the method to rawurlencode(rawurldecode($body)).

The existing literal-<script> tests never reached sanitizeBody, since HTMLPurifier_URIParser excludes ["<>] from the path and query and had already emptied the body; add cases that use percent-encoded markup so the code actually runs, and pin the round trip at the AttrDef level where the generic encoder runs first.
sms://5551234?body=Send%20money parsed the recipient into the authority, which doValidate discarded, leaving sms:?body=Send%20money — the attacker-supplied message survived while the number it was addressed to did not.

Keep the authority as a fallback recipient candidate so the number is recovered, and drop the body whenever the cleaned phone number is empty, so a bodied recipient-less link is never produced. A non-numeric authority such as sms://example.com?body=hi now reduces to sms: rather than keeping the message. Recipients written with a leading plus are still lost to HTMLPurifier_URI::validate(), which rejects them as hostnames before the scheme runs; the body is dropped in that case too.

Extract the digits-and-leading-plus reduction into cleanPhoneNumber() now that it runs against two candidates.
The class docblock claimed each body form is "re-emitted the way it came in", which only holds when one form is present; a query body overrides a path body, including when that value is empty. Say so, note that reading the body depends on %URI.AllowedSymbols keeping "&" and "=", and match the comment register the sibling schemes use rather than explaining at length.

Assign path and query once as defaults and override only when a body exists, instead of repeating both across three branches.
@maxfenton

Copy link
Copy Markdown
Author

All three changes are in, plus fixes found while going through the rest. Four commits since your review.

Requested

  1. The sms branch is gone from URIParser — that file is byte-identical to master again. URIParserTest now expects what the grammar produces: path '5555&body=HOME', query null.

  2. &body= and ?body= are kept as written rather than normalized into each other. RFC 3986 treats only ? as the query delimiter, so which form the author used is already encoded in where the body arrives. A URI carrying both resolves to the query value.

  3. schema.ser is the unmodified output of maintenance/generate-schema-cache.php — there is no generate-schema.php — and its only delta against master is URI.AllowedSchemes gaining sms => true. Both include manifests were hand-edited too; they are now regenerated.

Also fixed

sanitizeBody() was destroying message text. sms:911?body=I%20%3C3%20you came out sms:911&body=, and He said "hi" came out He said hi. rawurlencode() already makes the value inert, so it is now rawurlencode(rawurldecode($body)).

sms:5555?BODY=HOME came out sms:5555; RFC 5234 makes the field name case-insensitive, so it is matched in any case now.

sms://5551234?body=X came out sms:&body=X — recipient gone, message kept. A body is no longer emitted without a recipient.

226/226, green on 5.6 through 8.5.

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