fix(gallery): block password form in Instagram in-app browser and unmask login errors (#863)

* fix(gallery): block password form in Instagram in-app browser (#654)

Field reports show gallery password login still failing inside
Instagram's IAB after the #656 input-attribute/trim defenses. Three
changes:

- Replace the advisory amber banner with a red blocking state: the
  password form is hidden in the Instagram IAB and replaced with
  platform-specific "open in external browser" instructions plus a
  copy-link button (clipboard API with execCommand fallback). A
  "try anyway" link restores the form as an escape hatch.
- Stop masking non-password failures as "incorrect password": a request
  that never got a response (offline, webview killed it) now reports a
  connection error, and a reCAPTCHA 400 reports a verification failure —
  both previously fell through to the wrong-password message and sent
  guests chasing the wrong cause.
- Strip invisible Unicode (zero-width chars, word joiner, BOM, soft
  hyphen) from the submitted password in addition to trimming — these
  ride along when the password is copy-pasted out of a chat app and fail
  byte-exact bcrypt compare server-side.

* fix(gallery): retry login with typed password + honor execCommand result (#654)

Codex review round 1:
- Stored passwords can legitimately contain the invisible code points the
  sanitizer strips (e.g. ZWJ emoji sequences) — creation paths don't
  normalize. On a 401 where the sanitized form differs from the typed
  (trimmed) input, retry once with the typed value. Skipped when a
  reCAPTCHA token is in play (single-use).
- document.execCommand('copy') signals failure via its return value, not
  by throwing — only show "Link copied" when it returns true.

* fix(gallery): move invisible-char password fallback server-side (#654)

Codex review round 2: the client-side retry either burned the single-use
reCAPTCHA token (making exotic-but-valid passwords impossible to enter
with reCAPTCHA on) or burned failed-attempt lockout quota on every
rescued login. Doing the fallback as a second bcrypt compare inside the
same gallery/verify request eliminates both: exact bytes are compared
first (stored passwords containing e.g. ZWJ emoji keep working), the
sanitized form only on mismatch, and trackFailedAttempt only fires when
both fail. Frontend goes back to plain trim-on-submit; the client-side
sanitizer util and retry are removed. 7 integration tests pin the
contract.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-23 21:45:07 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 9ac23e5fe1
commit 323dcae917
6 changed files with 279 additions and 39 deletions
+8 -3
View File
@@ -804,11 +804,16 @@
"tooManyAttempts": "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.",
"pleaseEnterPassword": "Bitte geben Sie ein Passwort ein",
"passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben.",
"networkError": "Verbindung fehlgeschlagen. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"recaptchaFailed": "Sicherheitsprüfung fehlgeschlagen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.",
"iab": {
"instagram": {
"title": "Diesen Link im Browser öffnen",
"ios": "Der eingebaute Browser von Instagram blockiert das Passwort-Login manchmal. Oben rechts auf das ⋯-Menü tippen und „Im externen Browser öffnen“ wählen (oder den Link kopieren und in Safari einfügen).",
"android": "Der eingebaute Browser von Instagram blockiert das Passwort-Login manchmal. Oben rechts auf das ⋮-Menü tippen und „Im externen Browser öffnen“ wählen (oder den Link kopieren und in Chrome einfügen)."
"blockedTitle": "Der Instagram-Browser kann diese Galerie nicht öffnen",
"blockedIos": "Das Passwort-Login funktioniert im eingebauten Browser von Instagram nicht zuverlässig. Oben rechts auf das ⋯-Menü tippen und „Im externen Browser öffnen“ wählen oder den Link kopieren und in Safari einfügen.",
"blockedAndroid": "Das Passwort-Login funktioniert im eingebauten Browser von Instagram nicht zuverlässig. Oben rechts auf das ⋮-Menü tippen und „Im externen Browser öffnen“ wählen oder den Link kopieren und in Chrome einfügen.",
"copyLink": "Link kopieren",
"linkCopied": "Link kopiert",
"tryAnyway": "Passwort trotzdem hier eingeben"
}
}
},
+8 -3
View File
@@ -351,11 +351,16 @@
"tooManyAttempts": "Too many failed login attempts. Please try again later.",
"pleaseEnterPassword": "Please enter a password",
"passwordHint": "The password was provided by the event organizer. Contact them if you don't have it.",
"networkError": "Connection failed. Please check your internet connection and try again.",
"recaptchaFailed": "Security verification failed. Please reload the page and try again.",
"iab": {
"instagram": {
"title": "Open this link in your browser",
"ios": "Instagram's built-in browser sometimes blocks the password login. Tap the ⋯ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Safari).",
"android": "Instagram's built-in browser sometimes blocks the password login. Tap the ⋮ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Chrome)."
"blockedTitle": "Instagram's browser can't open this gallery",
"blockedIos": "Password login does not work reliably in Instagram's built-in browser. Tap the ⋯ menu in the top right and choose \"Open in external browser\", or copy the link and paste it into Safari.",
"blockedAndroid": "Password login does not work reliably in Instagram's built-in browser. Tap the ⋮ menu in the top right and choose \"Open in external browser\", or copy the link and paste it into Chrome.",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"tryAnyway": "Try entering the password here anyway"
}
}
},
+107 -32
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { AlertCircle, Clock } from 'lucide-react';
import { AlertCircle, Check, Clock, Copy } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
@@ -32,6 +32,13 @@ export const GalleryPage: React.FC = () => {
// Evaluate once per mount — UA doesn't change at runtime, and using useMemo
// avoids re-running detection on every render of the form.
const iabDetection = React.useMemo(() => detectInAppBrowser(), []);
// Field reports on #654 show the password form failing inside Instagram's
// IAB even with the input-attribute/trim defenses, so the form is hidden
// there by default. `iabOverride` is the guest's escape hatch (also covers
// a UA-detection false positive, or Instagram fixing their webview).
const [iabOverride, setIabOverride] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
const iabBlocked = iabDetection.app === 'instagram' && !iabOverride;
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
return null;
@@ -219,12 +226,12 @@ export const GalleryPage: React.FC = () => {
// Trim the password before sending. The Instagram in-app browser's
// predictive-text keyboard frequently appends a trailing space when the
// user taps the submit button, which then fails byte-exact bcrypt
// compare on the backend with no visible cause (#654). Event-gallery
// passwords don't legitimately carry leading/trailing whitespace, so
// trimming silently is safe.
// compare on the backend with no visible cause (#654). Mid-string
// invisible Unicode from chat-app copy-paste is handled server-side as
// a same-request compare fallback (see auth.js gallery/verify).
const submittedPassword = requiresPassword ? password.trim() : '';
await login(resolvedSlug, submittedPassword, recaptchaToken);
if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', {
gallery: resolvedSlug,
@@ -233,11 +240,20 @@ export const GalleryPage: React.FC = () => {
}
} catch (error: any) {
console.error('Login error:', error);
const errorMessage = error.response?.data?.error || 'Invalid password';
const errorMessage = error.response?.data?.error || '';
const statusCode = error.response?.status;
// Map backend error messages to user-friendly translations
if (statusCode === 401 || errorMessage.toLowerCase().includes('invalid password')) {
if (!error.response) {
// The request never got a response (offline, proxy/webview killed
// it). Falling through to "incorrect password" here sent #654
// reporters chasing the wrong cause — name the real failure.
setLoginError(t('auth.networkError', 'Connection failed. Please check your internet connection and try again.'));
} else if (statusCode === 400 && errorMessage.toLowerCase().includes('recaptcha')) {
// reCAPTCHA rejection is not a wrong password either — the widget
// regularly fails to load inside in-app webviews (#654).
setLoginError(t('auth.recaptchaFailed', 'Security verification failed. Please reload the page and try again.'));
} else if (statusCode === 401 || errorMessage.toLowerCase().includes('invalid password')) {
setLoginError(t('auth.wrongPassword'));
} else if (statusCode === 429 || errorMessage.toLowerCase().includes('too many')) {
setLoginError(t('auth.tooManyAttempts'));
@@ -263,6 +279,35 @@ export const GalleryPage: React.FC = () => {
}
};
const handleCopyLink = async () => {
const url = window.location.href;
try {
await navigator.clipboard.writeText(url);
setLinkCopied(true);
} catch {
// The async clipboard API is often unavailable inside in-app webviews —
// fall back to the legacy textarea + execCommand path.
const textarea = document.createElement('textarea');
textarea.value = url;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
// execCommand signals failure via its return value, not by throwing —
// only report "copied" when it actually worked; otherwise leave the
// label unchanged and the user can still long-press the address bar.
if (document.execCommand('copy')) {
setLinkCopied(true);
}
} catch {
// Same as a false return: keep the label unchanged.
}
document.body.removeChild(textarea);
}
};
// Show the same skeleton GalleryView uses while photos load, so the
// visitor sees one continuous loading state from URL open to real photos
// instead of three different full-page interstitials (#321).
@@ -396,33 +441,60 @@ export const GalleryPage: React.FC = () => {
<CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
{/* Instagram in-app browser warning (#654). The IAB's keyboard
bridge silently mangles password inputs (autocaps overrides,
predictive-text-appended trailing spaces, stale autofill).
Detect it and surface a "open in your normal browser" hint
so the user can self-rescue. */}
{iabDetection.app === 'instagram' && (
{/* Instagram in-app browser blocker (#654). Field reports show
password login failing inside the IAB even with the
input-attribute + trim defenses, so instead of a warning
above the form we replace the form: "open in your normal
browser" instructions plus a copy-link button. "Try anyway"
restores the form as an escape hatch (UA false positive, or
Instagram fixing their webview). */}
{iabBlocked && (
<div
role="alert"
className="mb-4 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 p-3 text-sm text-amber-900 dark:text-amber-100"
className="rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950/40 p-4 text-sm text-red-900 dark:text-red-100"
>
<p className="font-medium">
{t('auth.iab.instagram.title', 'Open this link in your browser')}
</p>
<p className="mt-1 text-xs">
{iabDetection.platform === 'ios'
? t(
'auth.iab.instagram.ios',
"Instagram's built-in browser sometimes blocks the password login. Tap the ⋯ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Safari).",
)
: t(
'auth.iab.instagram.android',
"Instagram's built-in browser sometimes blocks the password login. Tap the ⋮ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Chrome).",
)}
</p>
<div className="flex items-start">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p className="font-medium">
{t('auth.iab.instagram.blockedTitle', "Instagram's browser can't open this gallery")}
</p>
<p className="mt-1 text-xs">
{iabDetection.platform === 'ios'
? t(
'auth.iab.instagram.blockedIos',
'Password login does not work reliably in Instagram\'s built-in browser. Tap the ⋯ menu in the top right and choose "Open in external browser", or copy the link and paste it into Safari.',
)
: t(
'auth.iab.instagram.blockedAndroid',
'Password login does not work reliably in Instagram\'s built-in browser. Tap the ⋮ menu in the top right and choose "Open in external browser", or copy the link and paste it into Chrome.',
)}
</p>
</div>
</div>
<Button
type="button"
variant="primary"
size="lg"
className="w-full mt-4 text-sm sm:text-base"
onClick={handleCopyLink}
leftIcon={linkCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{linkCopied
? t('auth.iab.instagram.linkCopied', 'Link copied')
: t('auth.iab.instagram.copyLink', 'Copy link')}
</Button>
<button
type="button"
onClick={() => setIabOverride(true)}
className="mt-3 w-full text-center text-xs text-red-800 dark:text-red-200 underline"
>
{t('auth.iab.instagram.tryAnyway', 'Try entering the password here anyway')}
</button>
</div>
)}
{!iabBlocked && (
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
@@ -462,10 +534,13 @@ export const GalleryPage: React.FC = () => {
{t('gallery.viewGallery')}
</Button>
</form>
)}
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
{!iabBlocked && (
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
)}
</CardContent>
</Card>