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 <paul@MacStudio-von-Paul.local>
This commit is contained in:
Paul Nothaft
2026-07-23 21:45:07 +02:00
committed by GitHub
parent 9ac23e5fe1
commit 323dcae917
6 changed files with 279 additions and 39 deletions
@@ -0,0 +1,121 @@
/**
* Gallery password invisible-Unicode fallback (#654).
*
* Passwords relayed through chat apps (Instagram DMs especially) pick up
* invisible characters on copy-paste — zero-width space/joiners, word
* joiner, BOM, soft hyphen — which fail the byte-exact bcrypt compare and
* surface as "incorrect password" for a correct password. The verify route
* retries the compare with those characters stripped, in the SAME request,
* so the fallback costs no reCAPTCHA token and no failed-attempt quota.
*
* Pins the contract:
* - exact submitted bytes always win first, so stored passwords that
* legitimately contain these characters (e.g. ZWJ emoji sequences)
* keep working
* - paste artifacts (mid-string ZWSP, leading BOM, trailing space) are
* rescued by the sanitized fallback compare
* - the fallback never invents a match (missing ZWJ still 401s), and a
* rescued login records no failed attempt
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sanitize-test-secret';
const PLAIN_SLUG = 'sanitize-plain-event';
const ZWJ_SLUG = 'sanitize-zwj-event';
const PLAIN_PASSWORD = 'wedding2026';
// Stored password legitimately containing a ZWJ emoji sequence.
const ZWJ_PASSWORD = 'Family\u{1F468}\u200D\u{1F469}Aa1';
describe('gallery/verify invisible-Unicode fallback (#654)', () => {
let db;
let cleanup;
let app;
const makeEvent = async (slug, password) => {
const inserted = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Sanitize ${slug}`,
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: await bcrypt.hash(password, 4),
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
};
let plainEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
plainEventId = await makeEvent(PLAIN_SLUG, PLAIN_PASSWORD);
await makeEvent(ZWJ_SLUG, ZWJ_PASSWORD);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', require('../../src/routes/auth'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const verify = (slug, password) =>
request(app).post('/api/auth/gallery/verify').send({ slug, password });
it('accepts the exact password', async () => {
const res = await verify(PLAIN_SLUG, PLAIN_PASSWORD);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('rescues a mid-string zero-width space from chat-app copy-paste', async () => {
const res = await verify(PLAIN_SLUG, 'wedding\u200B2026');
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('rescues leading BOM + trailing space paste artifacts', async () => {
const res = await verify(PLAIN_SLUG, `\uFEFF${PLAIN_PASSWORD} `);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('records no login_fail for a rescued login (single-request fallback)', async () => {
await verify(PLAIN_SLUG, 'wedding\u200B2026').expect(200);
const failed = await db('access_logs')
.where({ event_id: plainEventId, action: 'login_fail' });
expect(failed).toHaveLength(0);
});
it('still accepts a stored password that legitimately contains a ZWJ', async () => {
const res = await verify(ZWJ_SLUG, ZWJ_PASSWORD);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('does not invent a match when the ZWJ is missing from the input', async () => {
const res = await verify(ZWJ_SLUG, 'Family\u{1F468}\u{1F469}Aa1');
expect(res.status).toBe(401);
});
it('rejects a plain wrong password', async () => {
const res = await verify(PLAIN_SLUG, 'not-the-password');
expect(res.status).toBe(401);
});
});
+14 -1
View File
@@ -28,6 +28,7 @@ const {
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const { sanitizePasswordInput } = require('../utils/passwordInput');
const {
validatePasswordInContext,
getBcryptRounds,
@@ -388,7 +389,19 @@ router.post('/gallery/verify', [
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
let validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
// Passwords copy-pasted out of chat apps carry invisible Unicode
// that fails the byte-exact compare (#654). Retry the compare with
// those characters stripped — in the SAME request, so the fallback
// costs no reCAPTCHA token and no failed-attempt quota. Exact bytes
// are tried first so stored passwords that legitimately contain
// such characters keep working.
const sanitized = sanitizePasswordInput(password);
if (sanitized !== password) {
validPassword = await bcrypt.compare(sanitized, event.password_hash);
}
}
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
+21
View File
@@ -0,0 +1,21 @@
/**
* Strip invisible Unicode from a guest-submitted gallery password (#654).
*
* Gallery passwords are usually relayed to guests through chat apps —
* Instagram DMs especially — and copy-pasting from those surfaces drags
* invisible characters along with the visible ones: zero-width
* space/joiners (U+200BU+200D), word joiner (U+2060), BOM (U+FEFF), soft
* hyphen (U+00AD). Those fail the byte-exact bcrypt compare with a plain
* "incorrect password" verdict and no visible cause.
*
* Used by the gallery verify route as a same-request compare fallback: the
* exact submitted bytes are always tried first, so stored passwords that
* legitimately contain these characters keep working.
*/
const INVISIBLE_CHARS = /[\u200B-\u200D\u2060\uFEFF\u00AD]/g;
function sanitizePasswordInput(raw) {
return String(raw).replace(INVISIBLE_CHARS, '').trim();
}
module.exports = { sanitizePasswordInput };
+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>