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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user