Skip to content

Commit d13ec42

Browse files
authored
Merge pull request #20658 from mozilla/FXA-13808
fix(passkeys): validate base64url WebAuthn fields at the route boundary
2 parents 295ba0b + dcada0f commit d13ec42

2 files changed

Lines changed: 335 additions & 10 deletions

File tree

packages/fxa-auth-server/lib/routes/passkeys.spec.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import type { Schema } from 'joi';
56
import { Container } from 'typedi';
67
import { PasskeyService } from '@fxa/accounts/passkey';
78
import { AppError } from '@fxa/accounts/errors';
@@ -1609,4 +1610,295 @@ describe('passkeys routes', () => {
16091610
).rejects.toThrow('DB unavailable');
16101611
});
16111612
});
1613+
1614+
describe('credentialId payload validation', () => {
1615+
const VALID_CRED_ID = 'A_z-09Aa';
1616+
const VALID_CHALLENGE = 'A_z-09';
1617+
const VALID_AUTH_INNER = {
1618+
clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0In0',
1619+
authenticatorData: 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ',
1620+
signature: 'MEUCIQCx',
1621+
};
1622+
const VALID_REG_INNER = {
1623+
clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0',
1624+
attestationObject: 'o2NmbXRkbm9uZQ',
1625+
};
1626+
1627+
function getSchema(
1628+
path: string,
1629+
method: string,
1630+
kind: 'payload' | 'params'
1631+
): Schema {
1632+
const all = passkeyRoutes(customs, db, config, statsd, glean, log);
1633+
const route = all.find(
1634+
(r: any) => r.path === path && r.method === method
1635+
);
1636+
if (!route) {
1637+
throw new Error(`Route not found: ${method} ${path}`);
1638+
}
1639+
return route.options.validate[kind];
1640+
}
1641+
1642+
const authPayload = (
1643+
responseOverride: Record<string, unknown> = {},
1644+
innerOverride: Record<string, unknown> = {},
1645+
challenge: string = VALID_CHALLENGE
1646+
) => ({
1647+
response: {
1648+
id: VALID_CRED_ID,
1649+
type: 'public-key',
1650+
response: { ...VALID_AUTH_INNER, ...innerOverride },
1651+
...responseOverride,
1652+
},
1653+
challenge,
1654+
});
1655+
1656+
const regPayload = (
1657+
responseOverride: Record<string, unknown> = {},
1658+
innerOverride: Record<string, unknown> = {},
1659+
challenge: string = VALID_CHALLENGE
1660+
) => ({
1661+
response: {
1662+
id: VALID_CRED_ID,
1663+
type: 'public-key',
1664+
response: { ...VALID_REG_INNER, ...innerOverride },
1665+
...responseOverride,
1666+
},
1667+
challenge,
1668+
});
1669+
1670+
describe('POST /passkey/authentication/finish', () => {
1671+
let schema: Schema;
1672+
beforeEach(() => {
1673+
schema = getSchema('/passkey/authentication/finish', 'POST', 'payload');
1674+
});
1675+
1676+
it('accepts a well-formed assertion payload', () => {
1677+
const { error } = schema.validate(authPayload());
1678+
expect(error).toBeUndefined();
1679+
});
1680+
1681+
it.each([
1682+
[
1683+
'shell-injection probe shape',
1684+
'(nslookup x.example.com||curl x.example.com)',
1685+
'string.pattern.base',
1686+
],
1687+
['contains slash', 'A/B', 'string.pattern.base'],
1688+
['contains plus', 'A+B', 'string.pattern.base'],
1689+
['contains equals padding', 'AA==', 'string.pattern.base'],
1690+
['empty string', '', 'string.empty'],
1691+
])('rejects response.id (%s)', (_label, badId, expectedType) => {
1692+
const { error } = schema.validate(authPayload({ id: badId }));
1693+
expect(error?.details).toEqual([
1694+
expect.objectContaining({
1695+
path: ['response', 'id'],
1696+
type: expectedType,
1697+
}),
1698+
]);
1699+
});
1700+
1701+
it('rejects response.id that exceeds the max length', () => {
1702+
const { error } = schema.validate(
1703+
authPayload({ id: 'A'.repeat(1365) })
1704+
);
1705+
expect(error?.details).toEqual([
1706+
expect.objectContaining({
1707+
path: ['response', 'id'],
1708+
type: 'string.max',
1709+
}),
1710+
]);
1711+
});
1712+
1713+
it('rejects a challenge longer than 64 chars', () => {
1714+
const { error } = schema.validate(authPayload({}, {}, 'A'.repeat(65)));
1715+
expect(error?.details).toEqual([
1716+
expect.objectContaining({
1717+
path: ['challenge'],
1718+
type: 'string.max',
1719+
}),
1720+
]);
1721+
});
1722+
1723+
it('rejects a non-base64url challenge', () => {
1724+
const { error } = schema.validate(authPayload({}, {}, 'has/slash'));
1725+
expect(error?.details).toEqual([
1726+
expect.objectContaining({
1727+
path: ['challenge'],
1728+
type: 'string.pattern.base',
1729+
}),
1730+
]);
1731+
});
1732+
1733+
it.each<[string, Record<string, string>]>([
1734+
['clientDataJSON', { clientDataJSON: 'has/slash' }],
1735+
['authenticatorData', { authenticatorData: 'has/slash' }],
1736+
['signature', { signature: 'has/slash' }],
1737+
['userHandle', { userHandle: 'has/slash' }],
1738+
])(
1739+
'rejects a non-base64url response.response.%s',
1740+
(field: string, innerOverride: Record<string, string>) => {
1741+
const { error } = schema.validate(authPayload({}, innerOverride));
1742+
expect(error?.details).toEqual([
1743+
expect.objectContaining({
1744+
path: ['response', 'response', field],
1745+
type: 'string.pattern.base',
1746+
}),
1747+
]);
1748+
}
1749+
);
1750+
1751+
it.each<[string]>([
1752+
['clientDataJSON'],
1753+
['authenticatorData'],
1754+
['signature'],
1755+
])(
1756+
'rejects when required response.response.%s is missing',
1757+
(field: string) => {
1758+
const inner: Record<string, string> = { ...VALID_AUTH_INNER };
1759+
delete inner[field];
1760+
const { error } = schema.validate({
1761+
response: {
1762+
id: VALID_CRED_ID,
1763+
type: 'public-key',
1764+
response: inner,
1765+
},
1766+
challenge: VALID_CHALLENGE,
1767+
});
1768+
expect(error?.details).toEqual([
1769+
expect.objectContaining({
1770+
path: ['response', 'response', field],
1771+
type: 'any.required',
1772+
}),
1773+
]);
1774+
}
1775+
);
1776+
});
1777+
1778+
describe('POST /passkey/registration/finish', () => {
1779+
let schema: Schema;
1780+
beforeEach(() => {
1781+
schema = getSchema('/passkey/registration/finish', 'POST', 'payload');
1782+
});
1783+
1784+
it('accepts a well-formed attestation payload', () => {
1785+
const { error } = schema.validate(regPayload());
1786+
expect(error).toBeUndefined();
1787+
});
1788+
1789+
it('rejects a non-base64url response.id', () => {
1790+
const { error } = schema.validate(regPayload({ id: 'has/slash' }));
1791+
expect(error?.details).toEqual([
1792+
expect.objectContaining({
1793+
path: ['response', 'id'],
1794+
type: 'string.pattern.base',
1795+
}),
1796+
]);
1797+
});
1798+
1799+
it('rejects a challenge longer than 64 chars', () => {
1800+
const { error } = schema.validate(regPayload({}, {}, 'A'.repeat(65)));
1801+
expect(error?.details).toEqual([
1802+
expect.objectContaining({
1803+
path: ['challenge'],
1804+
type: 'string.max',
1805+
}),
1806+
]);
1807+
});
1808+
1809+
it('rejects a non-base64url challenge', () => {
1810+
const { error } = schema.validate(regPayload({}, {}, 'has/slash'));
1811+
expect(error?.details).toEqual([
1812+
expect.objectContaining({
1813+
path: ['challenge'],
1814+
type: 'string.pattern.base',
1815+
}),
1816+
]);
1817+
});
1818+
1819+
it.each<[string, Record<string, string>]>([
1820+
['clientDataJSON', { clientDataJSON: 'has/slash' }],
1821+
['attestationObject', { attestationObject: 'has/slash' }],
1822+
['authenticatorData', { authenticatorData: 'has/slash' }],
1823+
['publicKey', { publicKey: 'has/slash' }],
1824+
])(
1825+
'rejects a non-base64url response.response.%s',
1826+
(field: string, innerOverride: Record<string, string>) => {
1827+
const { error } = schema.validate(regPayload({}, innerOverride));
1828+
expect(error?.details).toEqual([
1829+
expect.objectContaining({
1830+
path: ['response', 'response', field],
1831+
type: 'string.pattern.base',
1832+
}),
1833+
]);
1834+
}
1835+
);
1836+
1837+
it.each<[string]>([['clientDataJSON'], ['attestationObject']])(
1838+
'rejects when required response.response.%s is missing',
1839+
(field: string) => {
1840+
const inner: Record<string, string> = { ...VALID_REG_INNER };
1841+
delete inner[field];
1842+
const { error } = schema.validate({
1843+
response: {
1844+
id: VALID_CRED_ID,
1845+
type: 'public-key',
1846+
response: inner,
1847+
},
1848+
challenge: VALID_CHALLENGE,
1849+
});
1850+
expect(error?.details).toEqual([
1851+
expect.objectContaining({
1852+
path: ['response', 'response', field],
1853+
type: 'any.required',
1854+
}),
1855+
]);
1856+
}
1857+
);
1858+
});
1859+
1860+
describe('DELETE /passkey/{credentialId}', () => {
1861+
let schema: Schema;
1862+
beforeEach(() => {
1863+
schema = getSchema('/passkey/{credentialId}', 'DELETE', 'params');
1864+
});
1865+
1866+
it('accepts a base64url credentialId', () => {
1867+
const { error } = schema.validate({ credentialId: VALID_CRED_ID });
1868+
expect(error).toBeUndefined();
1869+
});
1870+
1871+
it('rejects a non-base64url credentialId', () => {
1872+
const { error } = schema.validate({ credentialId: 'has/slash' });
1873+
expect(error?.details).toEqual([
1874+
expect.objectContaining({
1875+
path: ['credentialId'],
1876+
type: 'string.pattern.base',
1877+
}),
1878+
]);
1879+
});
1880+
});
1881+
1882+
describe('PATCH /passkey/{credentialId}', () => {
1883+
let schema: Schema;
1884+
beforeEach(() => {
1885+
schema = getSchema('/passkey/{credentialId}', 'PATCH', 'params');
1886+
});
1887+
1888+
it('accepts a base64url credentialId', () => {
1889+
const { error } = schema.validate({ credentialId: VALID_CRED_ID });
1890+
expect(error).toBeUndefined();
1891+
});
1892+
1893+
it('rejects a non-base64url credentialId', () => {
1894+
const { error } = schema.validate({ credentialId: 'has/slash' });
1895+
expect(error?.details).toEqual([
1896+
expect.objectContaining({
1897+
path: ['credentialId'],
1898+
type: 'string.pattern.base',
1899+
}),
1900+
]);
1901+
});
1902+
});
1903+
});
16121904
});

packages/fxa-auth-server/lib/routes/passkeys.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ import { FxaMailerFormat } from '../senders/fxa-mailer-format';
2727
import { OAuthClientInfoServiceName } from '../senders/oauth_client_info';
2828
import { reportSentryError } from '../sentry';
2929

30+
// Without these, malformed base64url values throw inside SimpleWebAuthn /
31+
// `base64urlToBuffer` and Hapi surfaces a 500. Route-boundary validation
32+
// turns probes into 400s and keeps them out of Sentry. CredentialId max
33+
// matches the WebAuthn L2 ceiling (1023 bytes → 1364 base64url chars),
34+
// aligning with `passkeys.credentialId VARBINARY(1023)`.
35+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
36+
const base64urlString = (maxLen: number) =>
37+
isA.string().max(maxLen).regex(BASE64URL_PATTERN);
38+
const base64urlCredentialId = () => base64urlString(1364);
39+
const base64urlChallenge = () => base64urlString(64);
40+
3041
/** Subset of the Customs service used by passkey routes. */
3142
interface Customs {
3243
/**
@@ -783,8 +794,24 @@ export const passkeyRoutes = (
783794
},
784795
validate: {
785796
payload: isA.object({
786-
response: isA.object().required(),
787-
challenge: isA.string().required(),
797+
response: isA
798+
.object({
799+
id: base64urlCredentialId().required(),
800+
rawId: base64urlCredentialId().optional(),
801+
type: isA.string().valid('public-key').required(),
802+
response: isA
803+
.object({
804+
clientDataJSON: base64urlString(2048).required(),
805+
attestationObject: base64urlString(32768).required(),
806+
authenticatorData: base64urlString(16384).optional(),
807+
publicKey: base64urlString(1024).optional(),
808+
})
809+
.unknown(true)
810+
.required(),
811+
})
812+
.unknown(true)
813+
.required(),
814+
challenge: base64urlChallenge().required(),
788815
}),
789816
},
790817
response: {
@@ -851,7 +878,7 @@ export const passkeyRoutes = (
851878
},
852879
validate: {
853880
params: isA.object({
854-
credentialId: isA.string().required(),
881+
credentialId: base64urlCredentialId().required(),
855882
}),
856883
},
857884
response: {
@@ -903,16 +930,22 @@ export const passkeyRoutes = (
903930
payload: isA.object({
904931
response: isA
905932
.object({
906-
id: isA.string().required(),
933+
id: base64urlCredentialId().required(),
934+
rawId: base64urlCredentialId().optional(),
907935
type: isA.string().valid('public-key').required(),
936+
response: isA
937+
.object({
938+
clientDataJSON: base64urlString(2048).required(),
939+
authenticatorData: base64urlString(16384).required(),
940+
signature: base64urlString(1024).required(),
941+
userHandle: base64urlString(128).optional(),
942+
})
943+
.unknown(true)
944+
.required(),
908945
})
909946
.unknown(true)
910947
.required(),
911-
challenge: isA
912-
.string()
913-
.max(64)
914-
.regex(/^[A-Za-z0-9_-]+$/)
915-
.required(),
948+
challenge: base64urlChallenge().required(),
916949
service: validators.service.optional(),
917950
metricsContext: METRICS_CONTEXT_SCHEMA,
918951
}),
@@ -945,7 +978,7 @@ export const passkeyRoutes = (
945978
},
946979
validate: {
947980
params: isA.object({
948-
credentialId: isA.string().required(),
981+
credentialId: base64urlCredentialId().required(),
949982
}),
950983
payload: isA.object({
951984
name: isA.string().min(1).max(255).required(),

0 commit comments

Comments
 (0)