diff --git a/backend/__tests__/integration/galleryPasswordSanitize.test.js b/backend/__tests__/integration/galleryPasswordSanitize.test.js new file mode 100644 index 00000000..ded58641 --- /dev/null +++ b/backend/__tests__/integration/galleryPasswordSanitize.test.js @@ -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); + }); +}); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 0674b610..f6425720 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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({ diff --git a/backend/src/utils/passwordInput.js b/backend/src/utils/passwordInput.js new file mode 100644 index 00000000..0d544adf --- /dev/null +++ b/backend/src/utils/passwordInput.js @@ -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+200B–U+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 }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 36657ce1..8a156d98 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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" } } }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a753c5f3..e1acf7e2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" } } }, diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 811f25b7..41108a36 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -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(() => { 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 = () => {

{t('auth.enterPassword')}

- {/* 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 && (
-

- {t('auth.iab.instagram.title', 'Open this link in your browser')} -

-

- {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).", - )} -

+
+ +
+

+ {t('auth.iab.instagram.blockedTitle', "Instagram's browser can't open this gallery")} +

+

+ {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.', + )} +

+
+
+ +
)} + {!iabBlocked && (
{ {t('gallery.viewGallery')}
+ )} -

- {t('auth.passwordHint')} -

+ {!iabBlocked && ( +

+ {t('auth.passwordHint')} +

+ )}