fix(gallery): unbreak password entry in Instagram in-app browser (#654)

Reporter @Duecki1 hit "Incorrect Password" on byte-correct input from
Instagram's iOS/Android IAB. Backend bcrypt compare is fine — the
frontend was handing it a mangled byte sequence because the password
Input lacked the autocaps/autocorrect/spellcheck/autocomplete defenses
Instagram's WKWebView keyboard bridge needs (the standard `type="password"`
WebKit defaults that suppress autocaps get overridden inside the IAB).

Three layers of defense:

1. **Explicit input attributes** on the gallery password field —
   `autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`,
   `autoComplete="current-password"`. Stops iOS autocaps turning
   `wedding2026` into `Wedding2026`, stops predictive-text rewrites,
   nudges password managers to autofill the right credential rather
   than the IAB's stale saved-password store.

2. **Silent `.trim()` on submit** — Android Instagram IAB's predictive
   keyboard often appends a trailing space when the user taps the
   submit button. Event-gallery passwords don't legitimately carry
   leading/trailing whitespace (they're set by photographers, usually
   generated short strings), so trimming here is safe.

3. **Instagram IAB detection banner** — `frontend/src/utils/inAppBrowser.ts`
   detects the `Instagram` UA tag and surfaces a one-time advisory at
   the top of the password card with the right platform-specific
   "Open in external browser" instructions (⋯ menu copy for iOS,
   ⋮ for Android). Self-rescue path for users who hit it before we
   can close every keyboard mangling vector.

Scope is strictly Instagram per #654. Facebook IAB (`FBAV`/`FBAN`)
behaves identically and would benefit, but expanding the matcher is
a separate scope decision — the detector + i18n shape leaves room for
it without further refactor.

EN + DE i18n for the banner; 8 vitest cases on `detectInAppBrowser`
(iOS / Android Instagram UAs, plain Safari / Chrome / desktop UAs,
case-insensitive match, word-boundary defense against substring
collisions, SSR-safety when `navigator` is undefined). Lint + tsc
clean; pre-push Playwright smoke still expected green.

Closes #654.
This commit is contained in:
Paul Nothaft
2026-06-22 21:09:17 +02:00
parent df41d149e5
commit b1bfd4838e
5 changed files with 192 additions and 3 deletions
+8 -1
View File
@@ -693,7 +693,14 @@
"wrongPassword": "Falsches Passwort. Bitte überprüfen Sie Ihr Passwort und versuchen Sie es erneut.", "wrongPassword": "Falsches Passwort. Bitte überprüfen Sie Ihr Passwort und versuchen Sie es erneut.",
"tooManyAttempts": "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.", "tooManyAttempts": "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.",
"pleaseEnterPassword": "Bitte geben Sie ein Passwort ein", "pleaseEnterPassword": "Bitte geben Sie ein Passwort ein",
"passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben." "passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben.",
"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)."
}
}
}, },
"gallery": { "gallery": {
"expires": "Läuft ab", "expires": "Läuft ab",
+8 -1
View File
@@ -249,7 +249,14 @@
"wrongPassword": "Incorrect password. Please check your password and try again.", "wrongPassword": "Incorrect password. Please check your password and try again.",
"tooManyAttempts": "Too many failed login attempts. Please try again later.", "tooManyAttempts": "Too many failed login attempts. Please try again later.",
"pleaseEnterPassword": "Please enter a password", "pleaseEnterPassword": "Please enter a password",
"passwordHint": "The password was provided by the event organizer. Contact them if you don't have it." "passwordHint": "The password was provided by the event organizer. Contact them if you don't have it.",
"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)."
}
}
}, },
"gallery": { "gallery": {
"expires": "Expires", "expires": "Expires",
+50 -1
View File
@@ -16,6 +16,7 @@ import { galleryService } from '../services';
import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url'; import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
import { detectInAppBrowser } from '../utils/inAppBrowser';
export const GalleryPage: React.FC = () => { export const GalleryPage: React.FC = () => {
const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>(); const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
@@ -28,6 +29,9 @@ export const GalleryPage: React.FC = () => {
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null); const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
// 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(), []);
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => { const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) { if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
return null; return null;
@@ -212,7 +216,14 @@ export const GalleryPage: React.FC = () => {
return; return;
} }
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken); // 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.
const submittedPassword = requiresPassword ? password.trim() : '';
await login(resolvedSlug, submittedPassword, recaptchaToken);
if (requiresPassword) { if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', { analyticsService.trackGalleryEvent('password_entry', {
@@ -385,6 +396,33 @@ export const GalleryPage: React.FC = () => {
<CardContent className="p-4 sm:p-6"> <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> <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' && (
<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"
>
<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>
)}
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
<Input <Input
type="password" type="password"
@@ -394,6 +432,17 @@ export const GalleryPage: React.FC = () => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined} error={loginError || undefined}
autoFocus autoFocus
// Defend against in-app-browser keyboard mangling (#654):
// - autoCapitalize: stop iOS autocaps turning `wedding2026`
// into `Wedding2026` inside IAB WKWebViews
// - autoCorrect / spellCheck: stop predictive-text rewrites
// - autoComplete: tell password managers this is the
// current-password slot so they autofill the right value
// (vs the IAB's older saved-password store)
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoComplete="current-password"
className="text-sm sm:text-base" className="text-sm sm:text-base"
/> />
@@ -0,0 +1,75 @@
import { describe, expect, it, afterEach, vi } from 'vitest';
import { detectInAppBrowser } from '../inAppBrowser';
/**
* Tests for the Instagram IAB detection helper (#654). The detector is the
* gate that decides whether to surface the "open in browser" banner on the
* gallery password page, so we pin a handful of representative real-world
* UA strings here.
*/
function stubUserAgent(ua: string) {
vi.stubGlobal('navigator', { userAgent: ua });
}
describe('detectInAppBrowser', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('detects Instagram on iOS', () => {
stubUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 327.0.0.42.122',
);
expect(detectInAppBrowser()).toEqual({ app: 'instagram', platform: 'ios' });
});
it('detects Instagram on Android', () => {
stubUserAgent(
'Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Instagram 327.0.0.42.122 Android (34/14; 480dpi; 1080x2208; samsung; SM-S921B; e1q; qcom; en_US; 565243795)',
);
expect(detectInAppBrowser()).toEqual({ app: 'instagram', platform: 'android' });
});
it('returns null for plain Safari on iPhone', () => {
stubUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
);
expect(detectInAppBrowser()).toEqual({ app: null, platform: 'ios' });
});
it('returns null for plain Chrome on Android', () => {
stubUserAgent(
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36',
);
expect(detectInAppBrowser()).toEqual({ app: null, platform: 'android' });
});
it('returns null for desktop Chrome', () => {
stubUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
);
expect(detectInAppBrowser()).toEqual({ app: null, platform: 'other' });
});
it('matches `Instagram` case-insensitively (defensive against UA quirks)', () => {
stubUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 instagram 327.0.0',
);
expect(detectInAppBrowser().app).toBe('instagram');
});
it('does NOT match "Instagram" as a substring of an unrelated token', () => {
// Word-boundary match prevents matching, e.g. "FooInstagrambar" — vanishingly
// unlikely in real UAs but the regex should still be conservative.
stubUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 myInstagramReader/1.0',
);
expect(detectInAppBrowser().app).toBe(null);
});
it('returns app:null when navigator is undefined (SSR safety)', () => {
vi.stubGlobal('navigator', undefined);
expect(detectInAppBrowser()).toEqual({ app: null, platform: 'other' });
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Detect when the page is loaded inside a known social-app in-app browser
* (#654). These WKWebView / WebView wrappers — Instagram's IAB especially —
* mangle password inputs in ways the host app can't override: keyboard
* autocapitalisation overrides, predictive-text-appended trailing spaces,
* stale saved-password autofill, smart-quote substitution. The result is
* "Incorrect Password" on a byte-correct user input, with no visible cause.
*
* The cheapest mitigation is to detect the IAB UA and surface a banner
* asking the user to open the link in their device's normal browser; the
* gallery password form behaves correctly once we're out of the IAB.
*
* Detection uses navigator.userAgent. UA spoofing is possible but irrelevant
* here — the banner is advisory; the legitimate IAB UA strings are stable.
*/
export type InAppBrowser = 'instagram';
export interface InAppBrowserDetection {
// The detected IAB family, or null when the UA doesn't match.
app: InAppBrowser | null;
// Best-effort guess at the host platform so we can show the right copy
// for the "open in browser" instructions (the menu lives in different
// places on iOS vs Android).
platform: 'ios' | 'android' | 'other';
}
/**
* Detect whether the current navigator.userAgent matches a known IAB the
* gallery password form has trouble with. SSR-safe (returns app: null
* when window is undefined).
*/
export function detectInAppBrowser(): InAppBrowserDetection {
if (typeof navigator === 'undefined' || typeof navigator.userAgent !== 'string') {
return { app: null, platform: 'other' };
}
const ua = navigator.userAgent;
let platform: InAppBrowserDetection['platform'] = 'other';
if (/iPhone|iPad|iPod/i.test(ua)) platform = 'ios';
else if (/Android/i.test(ua)) platform = 'android';
// Instagram's IAB tags its UA with `Instagram <version>` on both iOS and
// Android. Match case-insensitively so versioning quirks (`Instagram` vs
// `instagram`) don't slip past.
if (/\bInstagram\b/i.test(ua)) {
return { app: 'instagram', platform };
}
return { app: null, platform };
}