323dcae917
* 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>
122 lines
4.2 KiB
JavaScript
122 lines
4.2 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|