b1bfd4838e
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.
52 lines
2.1 KiB
TypeScript
52 lines
2.1 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|