Fix various security issues #1

Merged
w33b merged 11 commits from fix-security-issues into main 2026-09-18 22:36:04 +02:00
Owner
No description provided.
w33b added 11 commits 2026-09-18 22:35:45 +02:00
The committed fixture is a MariaDB mysqldump. Its `/*M!...*/;` directives are
unknown to MySQL, which strips them to empty statements and then rejects the
bare `;` with error 1064. As a result tests/RefreshDatabase could not import
the dump at all against a MySQL server (verified on MySQL 8.4.11), leaving the
entire feature suite unrunnable.

The removed lines are a client sandbox hint and NOTE_VERBOSITY bookkeeping.
They carry no schema semantics, so dropping them is a no-op on MariaDB too.

Verified: import now succeeds and creates 31 tables; full suite 196 passed.
CVE-2026-71481: verifySolution skipped the HMAC check whenever the challenge
carried no signature, so a caller could submit a challenge it had fabricated
itself - no signature, cost 1 - solve it instantly, and pass verification.
That made every captcha-gated endpoint (register, login, contact,
/get-download) fully automatable.

- Upgrade altcha-org/altcha to ^2.0.3, which fixed this ("reject missing
  challenge signature when secret is set"). Installed v2.1.0.
- Also reject a missing signature inside the rule, so the property holds even
  with no secret configured: the library skips signature verification entirely
  in that case, which would silently reopen the bypass.
- Require ALTCHA_HMAC_KEY in .env.example. Without it challenges are issued
  unsigned, and captcha-gated forms now fail closed instead of degrading.

Verified with fixtures in tests/Support/AltchaPayload that reproduce the
reported payload shapes (self-issued, stripped signature): 3 of the 4 cases
fail against the vulnerable 2.0.2 and all pass on the fixed version. Full
suite: 200 passed.
PlaylistSidebar::$isOwner is a public Livewire property. The snapshot is
HMAC-signed, but the `updates` object of POST /livewire/update is not, and
Livewire applies it to any public property, so a caller could simply send
`updates: {isOwner: true}`. The component is rendered on the public stream
page, so an anonymous visitor could do this with a snapshot taken from any
accessible `?playlist=` URL.

remove() then looked the entry up globally, so the flipped flag let that
visitor delete any playlist_episodes row - including entries of private
playlists - and an owner could delete entries belonging to other playlists.

- Mark $isOwner #[Locked], which rejects client-side updates to it.
- Scope the remove() lookup to the mounted playlist, so the action cannot
  reach another playlist even if the flag is ever wrong again.
- Extract the snapshot-replay helper into Tests\Support\LivewireSnapshot and
  reuse it from the admin authorization test.

Verified: both attack cases fail against the unfixed component (the guest
replay really does delete the victim's row) and pass after the fix, while the
owner can still remove their own entry. Full suite: 203 passed.
The VideoObject block emitted json_encode($data, JSON_UNESCAPED_SLASHES) into
a <script type="application/ld+json"> element. json_encode does not escape < or
>, and JSON_UNESCAPED_SLASHES keeps "/" literal, so an episode title
containing "</script>" closed the script element early. Titles are
moderator-writable (POST /admin/episode/edit -> updateTitle), so a moderator
could run script for every visitor of the episode page, admins included, and
from there drive /admin/* with the admin's own CSRF token.

Adding JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT escapes
those characters as \uXXXX, which both prevents the early close and keeps the
block valid JSON-LD for crawlers.

Note: Js::from() must not be used here - it emits a JavaScript expression
(JSON.parse('...')), not JSON, which would break the structured data.

Verified: with the payload in a title the block is truncated and fails to
parse before the fix, and parses with the full title restored after it. Full
suite: 205 passed.
markdownBody() passed only html_input => 'strip' to CommonMark, leaving
allow_unsafe_links at its default of true. That option strips raw HTML tags but
does nothing about link destinations, so a comment containing
[javascript:alert(1)] rendered as a live href and executed in the site's origin
when clicked. Comment bodies are free text posted by any authenticated user and
are rendered unescaped on the stream, series, profile and home pages.

Verified: before the change the presenter returns
<p><a href="javascript:alert(1)">click me</a></p>; after it the destination is
dropped and only the link text remains. javascript:, vbscript: and data: are
covered by the new test. Full suite: 206 passed.
moveUp(), moveDown() and remove() checked isOwner() - which only proves
ownership of the playlist the component was mounted with - and then looked the
entry up with PlaylistEpisode::find($episodeId). Any user owning a playlist of
their own could therefore pass an entry id belonging to somebody else and
delete or reorder it, private playlists included. isOwner() is not client
writable, so this was reachable by any registered user with one playlist.

Note the sidebar had the same unscoped lookup; it is fixed in the preceding
commit together with its client-writable flag.

Verified: the new test fails against the unfixed component (a legitimate owner
really does delete and reorder another playlist's entries) and passes after,
with the 12 existing tests unchanged. Full suite: 207 passed.
Watched::$userId was an untyped public property set in mount() from the
authenticated user. The snapshot is signed but the `updates` object of
POST /livewire/update is not, so any signed-in user could send
`updates: {userId: <someone else>}` and page through that account's viewing
history.

Marking it #[Locked] and typing it as int makes Livewire reject client-side
updates to it; mount() still assigns it server-side.

Verified: before the change the tampered request returns 200 with the victim's
episodes rendered; after it the update is rejected and none of the victim's
data is returned. A companion test asserts the viewer still sees their own
history, so the assertion cannot pass just because the page is broken.

Note for future tests here: the rejection is a 500 whose Ignition debug page
echoes the calling source, so the test disables app.debug before checking the
body.
Hentai::isLoliOrShota() cached under "episode:{id}:has_problematic_tags",
the same key Episode::isLoliOrShota() uses. Hentai ids and Episode ids are
separate sequences that overlap, so whichever entity was resolved first
decided the answer for the other one holding that integer id, for the full 24
hour TTL. In the dangerous direction a tagged series is treated as safe and
guests are served content the login gate is meant to withhold.

The key is now namespaced per model. Swept every other Cache::remember key in
app/ for the same mistake: the remaining keys are either global literals or
prefixed per entity, and this was the only collision.

Verified: with a clean episode and a Loli-tagged series forced to share id
4242, the series reported false before the change and true after it. Full
suite: 210 passed.
The controller branched on the broker status: unknown addresses produced
withErrors(['email' => __('passwords.user')]) ("We can't find a user with that
e-mail address"), and only known ones flashed a status. Both are rendered by
auth/forgot-password.blade.php, so the form told anyone whether an address has
an account.

The response is now the same either way: a reset link is still sent when the
address belongs to an account, and the generic status is flashed regardless.
The trade-off is that a throttled request (second request within 60s) shows the
same generic message even though no mail is sent - the usual privacy trade-off.

Not fixed here: register still returns a unique-rule validation error for an
address that already exists, which is the same class of oracle. Its wording is
not the problem - the presence of the error is - so closing it means deferring
the answer behind an email verification step rather than changing a message.
Logged in SECURITY_FIX_PLAN.md for a decision.

Verified: the unknown-address case fails before the change and passes after,
with the known-address case unchanged. Full suite: 212 passed.
Two blade templates placed user-controlled strings inside single-quoted
JavaScript literals. HTML escaping does not escape quotes for JS, and
addslashes() does not escape newlines:

- profile/partials/user-playlists.blade.php interpolated the playlist name
  raw, so a name containing a quote closed the literal and the rest of the
  expression was parsed as code (self-XSS on the owner's own playlist page).
- livewire/admin-user-search.blade.php used addslashes() for the display name
  in the confirm dialogs. The quote was handled, so this is not executable, but
  a name containing a newline still breaks the expression and takes the admin
  user table's buttons with it.

Both now concatenate Js::from(), which emits a JSON-derived literal.

Verified by rendering with the name "O'Brien\n\"x\" </script>": the old output
contained a literal newline (and, for the playlist, 'O&#039;Brien closing the
string); the new output is 'O\u0027Brien\n\u0022x\u0022 \u003C\/script\u003E'
in both places. Also swept the other '<lit> {{ }} <lit>' JS interpolations in
resources/views - sortBy(), removeRoleFilter() and toggleOption() take
hard-coded arrays or values already intersected with allowedRoles, so they are
not injectable. Full suite: 212 passed.
HomeController built a raw FIELD() clause by imploding markable_id values into
SQL. markable_likes.markable_id is an unsigned bigint, so this is not
exploitable today, but the value was interpolated without any guarantee of its
type - one column change away from being a string that could carry SQL.

The ids are now cast to int before they are used for both the WHERE and the
raw ORDER BY, so the fragment can only ever contain digits.

Not changed: the FIELD(id, ) fragment is still a syntax error when no likes
exist at all, which makes the home page 500. That is pre-existing and known -
tests/Feature/HomeAlertBannerTest::setUp() seeds a like specifically to work
around it - so it is left as it is. Worth a separate decision.

Full suite: 212 passed.
w33b merged commit 8edcc19b61 into main 2026-09-18 22:36:04 +02:00
Sign in to join this conversation.
No reviewers
No labels
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
w33b/hstream!1
No description provided.