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
@@ -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 };
}