Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } =
app.use(`${prefix}/billing/webhook`, express.raw({ type: 'application/json' }), billingRoutes);
}

// Identity verification carries a base64 selfie, which cannot fit the 100KB
// cap below. Mounted BEFORE the global parser (express.json is a no-op once
// a body is parsed) so the larger limit applies to this path only, rather
// than raising the ceiling for every route in the app.
//
// 3MB accommodates the 2MB decoded image cap the routes enforce plus base64's
// ~33% expansion. The route still validates the decoded size, so this is the
// outer bound, not the real limit.
for (const prefix of ['', '/api']) {
app.use(`${prefix}/identity`, express.json({ limit: '3mb' }));
}

// ---------- Body parsing with explicit size cap ----------
// 100KB is generous for our payload shapes (invite acceptance, agency
// config updates, EVV punches) and prevents JSON-bomb DoS. Copilot is
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/routes/__tests__/identity-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,43 @@ describe('POST /identity/verify', () => {
});
});

describe('request body limits', () => {
it('accepts a realistically sized selfie', async () => {
// Regression: the app-wide JSON cap is 100KB, which no base64 photo can
// fit. Identity gets its own larger parser mounted ahead of it. Without
// that, every capture 413s before reaching the route and the whole
// feature is dead on arrival.
mockRepo();
mockStorage();
mockMatch('matched', 96);
const selfie = Buffer.alloc(600 * 1024, 7).toString('base64');

const res = await request(createApp())
.post('/identity/verify')
.set('Authorization', auth())
.send({ imageBase64: selfie });

expect(res.status).toBe(200);
expect(res.body.outcome).toBe('matched');
});

it('rejects an image past the decoded cap', async () => {
mockRepo();
const storage = mockStorage();
mockMatch('matched', 96);
// Over the 2MB decoded ceiling the route enforces.
const huge = Buffer.alloc(2.2 * 1024 * 1024, 7).toString('base64');

const res = await request(createApp())
.post('/identity/enroll')
.set('Authorization', auth())
.send({ imageBase64: huge });

expect(res.status).toBe(400);
expect(storage.uploadDocument).not.toHaveBeenCalled();
});
});

describe('GET /identity/status', () => {
it('states plainly that liveness is not supported', async () => {
mockRepo();
Expand Down
9 changes: 7 additions & 2 deletions packages/app/src/routes/identity-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ export const CONSENT_TEXT = [
'stored photograph will be deleted.',
].join(' ');

/** Base64 JPEG, capped so a single request cannot be used to push large blobs. */
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
/**
* Decoded image cap. A selfie for face matching does not need to be large:
* Rekognition wants roughly 80px of face width, and a compressed front-camera
* photo lands far under this. Kept in step with the 3MB body limit mounted for
* this path in app.ts, which allows for base64's expansion.
*/
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
const imageSchema = z.object({
imageBase64: z.string().min(100),
});
Expand Down
51 changes: 41 additions & 10 deletions packages/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,49 +22,71 @@
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeName",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"]
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
},
{
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeEmailAddress",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"]
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
},
{
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePreciseLocation",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"]
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
},
{
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeUserID",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"]
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
},
{
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeHealth",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"]
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
}
],
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": ["85F4.1", "E174.1"]
"NSPrivacyAccessedAPITypeReasons": [
"85F4.1",
"E174.1"
]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["0A2A.1", "3B52.1", "C617.1"]
"NSPrivacyAccessedAPITypeReasons": [
"0A2A.1",
"3B52.1",
"C617.1"
]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
"NSPrivacyAccessedAPITypeReasons": [
"35F9.1"
]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["1C8F.1", "CA92.1"]
"NSPrivacyAccessedAPITypeReasons": [
"1C8F.1",
"CA92.1"
]
}
]
}
Expand Down Expand Up @@ -103,7 +125,9 @@
{
"color": "#1a5fa8",
"defaultChannel": "shift-alerts-v2",
"sounds": ["./assets/sounds/shift_alarm.wav"]
"sounds": [
"./assets/sounds/shift_alarm.wav"
]
}
],
[
Expand All @@ -113,6 +137,13 @@
"isAndroidBackgroundLocationEnabled": false
}
],
[
"expo-camera",
{
"cameraPermission": "RayHealthEVV uses your camera only to take the identity photo you agreed to, so your agency can confirm it is you clocking in.",
"recordAudioAndroid": false
}
],
"expo-secure-store"
],
"experiments": {
Expand Down
2 changes: 2 additions & 0 deletions packages/mobile/app/identity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import IdentityScreen from '../src/features/identity/IdentityScreen';
export default IdentityScreen;
1 change: 1 addition & 0 deletions packages/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"axios": "^1.18.1",
"eslint-config-expo": "~10.0.0",
"expo": "^54.0.36",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
Expand Down
Loading
Loading