diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index 250e65dc..db869cb3 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -26,12 +26,12 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
@@ -40,7 +40,7 @@ jobs:
- name: Run Claude Code
id: claude
- uses: anthropics/claude-code-action@v1
+ uses: anthropics/claude-code-action@e63208cb983318a44e3f945e959ef894b707dcfa # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
diff --git a/.github/workflows/firebase-hosting-dev.yml b/.github/workflows/firebase-hosting-dev.yml
index 035fda66..d42eb7e6 100644
--- a/.github/workflows/firebase-hosting-dev.yml
+++ b/.github/workflows/firebase-hosting-dev.yml
@@ -19,13 +19,13 @@ jobs:
environment: ${{ matrix.environment }}
steps:
- run: echo 'Running deplyoment for project ${{ vars.PROJECT }}'
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- run: npm ci
- run: npm run build --project=${{ vars.PROJECT }}
- - uses: w9jds/setup-firebase@main
+ - uses: w9jds/setup-firebase@869785322147e6a53d463a55db0a5af1b4ce4ba6 # main (no release tag; pinned)
with:
project_id: ${{ vars.FIREBASE_PROJECT }}
tools-version: 14
@@ -47,11 +47,11 @@ jobs:
environment: ${{ matrix.environment }}
steps:
- run: echo 'Running functions deplyoment for project ${{ vars.PROJECT }}'
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- - uses: w9jds/setup-firebase@main
+ - uses: w9jds/setup-firebase@869785322147e6a53d463a55db0a5af1b4ce4ba6 # main (no release tag; pinned)
with:
project_id: ${{ vars.FIREBASE_PROJECT }}
tools-version: 14
diff --git a/.github/workflows/firebase-hosting-prod.yml b/.github/workflows/firebase-hosting-prod.yml
index b5fa4f02..6ea8573a 100644
--- a/.github/workflows/firebase-hosting-prod.yml
+++ b/.github/workflows/firebase-hosting-prod.yml
@@ -17,13 +17,13 @@ jobs:
environment: ${{ matrix.environment }}
steps:
- run: echo 'Running deplyoment for project ${{ vars.PROJECT }}'
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- run: npm ci
- run: npm run build:prod --project=${{ vars.PROJECT }}
- - uses: w9jds/setup-firebase@main
+ - uses: w9jds/setup-firebase@869785322147e6a53d463a55db0a5af1b4ce4ba6 # main (no release tag; pinned)
with:
project_id: ${{ vars.FIREBASE_PROJECT }}
tools-version: 14
@@ -43,11 +43,11 @@ jobs:
environment: ${{ matrix.environment }}
steps:
- run: echo 'Running functions deplyoment for project ${{ vars.PROJECT }}'
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- - uses: w9jds/setup-firebase@main
+ - uses: w9jds/setup-firebase@869785322147e6a53d463a55db0a5af1b4ce4ba6 # main (no release tag; pinned)
with:
project_id: ${{ vars.FIREBASE_PROJECT }}
tools-version: 14
diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml
index 66cfb7ee..ba6bec9a 100644
--- a/.github/workflows/test-pull-request.yml
+++ b/.github/workflows/test-pull-request.yml
@@ -5,8 +5,8 @@ jobs:
if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- run: npm ci
@@ -18,8 +18,8 @@ jobs:
if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- run: cd functions && npm ci
diff --git a/firebase-rules-template.json b/firebase-rules-template.json
index 5a2cfa52..986de95f 100644
--- a/firebase-rules-template.json
+++ b/firebase-rules-template.json
@@ -10,10 +10,10 @@
],
"$departure_id": {
".read": "{movementItemRead}",
- ".write": "auth !== null && (!root.child('settings/lockDate').exists() || (!data.exists() && newData.exists() && newData.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24) || (data.exists() && !newData.exists() && data.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24) || (data.exists() && newData.exists() && data.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24 && newData.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24)){movementOwnership}",
+ ".write": "auth !== null && ({movementLock}){movementOwnership}",
".validate": "newData.hasChildren(['aircraftType', 'dateTime', 'departureRoute', 'duration', 'email', 'firstname', 'flightType', 'immatriculation', 'lastname', 'location', 'mtow', 'aircraftCategory', 'negativeTimestamp'])",
"aircraftType": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"carriageVoucher": {
".validate": "newData.parent().child('passengerCount').val() === 0 || (newData.val() === 'yes' || newData.val() === 'no')"
@@ -28,34 +28,34 @@
".validate": "newData.isString() && newData.val().matches(/^\\d{2}:\\d{2}$/)"
},
"email": {
- ".validate": "newData.isString() && newData.val().matches(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$/)"
+ ".validate": "newData.isString() && newData.val().length <= 254 && newData.val().matches(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$/)"
},
"firstname": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"flightType": {
".validate": "{flightType}"
},
"immatriculation": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"lastname": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"location": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"locationName": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 200"
},
"locationCountry": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 100"
},
"locationTimezone": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 100"
},
"memberNr": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 50"
},
"mtow": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -70,28 +70,28 @@
".validate": "newData.isNumber() && newData.val() >= 0"
},
"phone": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 30"
},
"remarks": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 2000"
},
"route": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 500"
},
"runway": {
".validate": "{runway}"
},
"createdBy": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 254"
},
"createdBy_orderKey": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 320"
},
"customsFormId": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 200"
},
"customsFormUrl": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 512 && (!root.child('settings/customsDeclarationApp/baseUrl').exists() || newData.val().beginsWith(root.child('settings/customsDeclarationApp/baseUrl').val()))"
},
"privacyPolicyAcceptedAt": {
".validate": "newData.isString() && newData.val().matches(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/)"
@@ -111,10 +111,10 @@
],
"$arrival_id": {
".read": "{movementItemRead}",
- ".write": "auth !== null && (!root.child('settings/lockDate').exists() || (!data.exists() && newData.exists() && newData.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24) || (data.exists() && !newData.exists() && data.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24) || (data.exists() && newData.exists() && data.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24 && newData.child('negativeTimestamp').val() * -1 > root.child('settings/lockDate').val() + 1000 * 60 * 60 * 24)){movementOwnership}",
+ ".write": "auth !== null && ({movementLock}){movementOwnership}",
".validate": "newData.hasChildren(['aircraftType', 'arrivalRoute', 'dateTime', 'email', 'firstname', 'flightType', 'immatriculation', 'landingCount', 'lastname', 'location', 'mtow', 'aircraftCategory', 'negativeTimestamp'])",
"aircraftType": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"arrivalRoute": {
".validate": "{arrivalRoute}"
@@ -123,10 +123,10 @@
".validate": "newData.isString() && newData.val().matches(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/)"
},
"email": {
- ".validate": "newData.isString() && newData.val().matches(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$/)"
+ ".validate": "newData.isString() && newData.val().length <= 254 && newData.val().matches(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$/)"
},
"firstname": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"flightType": {
".validate": "{flightType}"
@@ -138,13 +138,13 @@
".validate": "newData.isNumber() && newData.val() >= 0"
},
"goAroundFeeCode": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 50 && newData.val().length > 0"
},
"goAroundFeeTotal": {
".validate": "newData.isNumber() && newData.val() >= 0"
},
"immatriculation": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"landingCount": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -153,7 +153,7 @@
".validate": "newData.isNumber() && newData.val() >= 0"
},
"landingFeeCode": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 50 && newData.val().length > 0"
},
"landingFeeTotal": {
".validate": "newData.isNumber() && newData.val() >= 0"
@@ -171,22 +171,22 @@
".validate": "newData.isNumber()"
},
"lastname": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"location": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"locationName": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 200"
},
"locationCountry": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 100"
},
"locationTimezone": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 100"
},
"memberNr": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 50"
},
"mtow": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -201,34 +201,34 @@
".validate": "newData.isNumber() && newData.val() >= 0"
},
"phone": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 30"
},
"remarks": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 2000"
},
"runway": {
".validate": "{runway}"
},
"createdBy": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 254"
},
"createdBy_orderKey": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 320"
},
"paymentMethod": {
".validate": "newData.hasChildren(['method'])",
"method": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.val() === 'card' || newData.val() === 'checkout' || newData.val() === 'cash' || newData.val() === 'card_external' || newData.val() === 'twint_external' || newData.val() === 'invoice'"
},
"invoiceRecipientName": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 200"
}
},
"customsFormId": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 200"
},
"customsFormUrl": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 512 && (!root.child('settings/customsDeclarationApp/baseUrl').exists() || newData.val().beginsWith(root.child('settings/customsDeclarationApp/baseUrl').val()))"
},
"privacyPolicyAcceptedAt": {
".validate": "newData.isString() && newData.val().matches(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/)"
@@ -244,18 +244,11 @@
},
"aircrafts": {
".read": "auth !== null",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".indexOn": [
"type"
]
},
- "users": {
- ".read": "auth !== null",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()",
- ".indexOn": [
- "memberNr"
- ]
- },
"aerodromes": {
".read": "auth !== null",
".write": false,
@@ -266,27 +259,27 @@
"settings": {
"lockDate": {
".read": "auth !== null",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()"
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true"
},
"guestAccessToken": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".write": false
},
"kioskAccessToken": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".write": false
},
"aircrafts": {
"homeBase": {
".read": "auth !== null",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()"
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true"
},
"club": {
".read": "auth !== null",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()"
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true"
},
"custom": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".write": false
},
"$other": {
@@ -294,27 +287,27 @@
}
},
"invoiceRecipients": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()"
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true"
},
"privacyPolicyUrl": {
".read": true,
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".validate": "newData.isString()"
},
"movementRetentionDays": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".validate": "newData.isNumber() && newData.val() > 0"
},
"messageRetentionDays": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".validate": "newData.isNumber() && newData.val() > 0"
},
"aerodromeStatusBannerEnabled": {
".read": true,
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".validate": "newData.isBoolean()"
},
"$other": {
@@ -322,7 +315,7 @@
}
},
"messages": {
- ".read": "auth !== null && root.child('admins/' + auth.uid).exists()",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".indexOn": [
"negativeTimestamp"
],
@@ -330,16 +323,16 @@
".write": "auth !== null && !data.exists() && newData.exists()",
".validate": "newData.hasChildren(['name', 'email', 'message', 'timestamp', 'negativeTimestamp'])",
"name": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 200 && newData.val().length > 0"
},
"email": {
- ".validate": "newData.isString() && newData.val().matches(/^(([^<>()\\[\\]\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/)"
+ ".validate": "newData.isString() && newData.val().length <= 254 && newData.val().matches(/^(([^<>()\\[\\]\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/)"
},
"phone": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 30"
},
"message": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 5000 && newData.val().length > 0"
},
"timestamp": {
".validate": "newData.isNumber() && newData.val() > 0 && newData.val() === newData.parent().child('negativeTimestamp').val() * -1"
@@ -368,12 +361,12 @@
}
},
"status": {
- ".read": "true",
+ ".read": "auth !== null && root.child('admins/' + auth.uid).val() === true",
".indexOn": [
"timestamp"
],
"$status_id": {
- ".write": "auth !== null && root.child('admins/' + auth.uid).exists() && !data.exists() && newData.exists()",
+ ".write": "auth !== null && root.child('admins/' + auth.uid).val() === true && !data.exists() && newData.exists()",
".validate": "newData.hasChildren(['status', 'details', 'timestamp', 'by'])",
"status": {
".validate": "newData.val() === 'open' || newData.val() === 'restricted' || newData.val() === 'closed'"
@@ -406,11 +399,14 @@
},
"card-payments": {
"$card_payment_id": {
- ".read": "auth !== null",
- ".write": "auth !== null && newData.exists() && (root.child('admins/' + auth.uid).exists() || (!data.exists() && newData.child('status').val() === 'pending') || (data.exists() && data.child('status').val() === 'pending' && newData.child('status').val() === 'cancelled'))",
+ ".read": "{cardPaymentRead}",
+ ".write": "{cardPaymentWrite}",
".validate": "newData.hasChildren(['amount', 'currency', 'arrivalReference', 'refNr', 'timestamp', 'status'])",
+ "owner": {
+ ".validate": "newData.isString() && newData.val().length <= 128 && newData.val().length > 0"
+ },
"currency": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 10 && newData.val().length > 0"
},
"amount": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -419,22 +415,22 @@
".validate": "newData.isNumber() && newData.val() > 0"
},
"arrivalReference": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 64 && newData.val().length > 0"
},
"refNr": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 128 && newData.val().length > 0"
},
"status": {
".validate": "newData.val() === 'pending' || newData.val() === 'success' || newData.val() === 'failure' || newData.val() === 'cancelled' || newData.val() === 'inprogress'"
},
"method": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 50 && newData.val().length > 0"
},
"email": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 254 && newData.val().length > 0"
},
"immatriculation": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"landings": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -443,7 +439,7 @@
".validate": "newData.isNumber() && newData.val() > 0"
},
"landingFeeCode": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 50"
},
"landingFeeTotal": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -455,7 +451,7 @@
".validate": "newData.isNumber() && newData.val() > 0"
},
"goAroundFeeCode": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 50 && newData.val().length > 0"
},
"goAroundFeeTotal": {
".validate": "newData.isNumber() && newData.val() > 0"
@@ -474,6 +470,10 @@
".read": false,
".write": false
},
+ "staticAuthRateLimits": {
+ ".read": false,
+ ".write": false
+ },
"webauthnCredentials": {
".write": false,
"$uid": {
@@ -482,7 +482,8 @@
},
"webauthnChallenges": {
".read": false,
- ".write": false
+ ".write": false,
+ ".indexOn": ["expiry"]
},
"webauthnCredentialOwners": {
".read": false,
@@ -493,25 +494,25 @@
".read": "auth !== null && $profile_id === auth.uid",
".write": "auth !== null && $profile_id === auth.uid",
"memberNr": {
- ".validate": "newData.isString()"
+ ".validate": "newData.isString() && newData.val().length <= 50"
},
"email": {
- ".validate": "newData.isString() && newData.val().matches(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$/)"
+ ".validate": "newData.isString() && newData.val().length <= 254 && newData.val().matches(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$/)"
},
"firstname": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"lastname": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 100 && newData.val().length > 0"
},
"phone": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 30 && newData.val().length > 0"
},
"immatriculation": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"aircraftType": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"aircraftCategory": {
".validate": "{aircraftCategory}"
@@ -526,7 +527,7 @@
"$index": {
".validate": "newData.hasChildren(['immatriculation'])",
"immatriculation": {
- ".validate": "newData.isString() && newData.val().length > 0"
+ ".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0"
},
"aircraftType": {
".validate": "newData.isString()"
diff --git a/firebase.json b/firebase.json
index 9af05257..164e59ba 100644
--- a/firebase.json
+++ b/firebase.json
@@ -52,6 +52,16 @@
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
+ },
+ {
+ "source": "**",
+ "headers": [
+ { "key": "X-Frame-Options", "value": "DENY" },
+ { "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" },
+ { "key": "X-Content-Type-Options", "value": "nosniff" },
+ { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
+ { "key": "Permissions-Policy", "value": "geolocation=(), camera=(), microphone=(), payment=()" }
+ ]
}
]
},
diff --git a/functions/api/basicAuth.js b/functions/api/basicAuth.js
deleted file mode 100644
index 9a13bd52..00000000
--- a/functions/api/basicAuth.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const expectedUsername = process.env.API_SERVICEUSER_USERNAME
-const expectedPassword = process.env.API_SERVICEUSER_PASSWORD
-
-const basicAuth = (req, res, next) => {
- if (!expectedUsername || !expectedPassword) {
- console.info(
- "Set API_SERVICEUSER_USERNAME and API_SERVICEUSER_PASSWORD env vars for the API auth"
- )
- res.status(401).send('Unauthorized')
- return
- }
-
- const authHeader = req.headers.authorization || ''
- const [type, credentials] = authHeader.split(' ')
-
- if (type === 'Basic' && credentials) {
- const decoded = Buffer.from(credentials, 'base64').toString('utf-8')
- const [username, password] = decoded.split(':')
-
- if (username === expectedUsername && password === expectedPassword) {
- return next()
- }
- }
-
- res.status(401).send('Unauthorized')
-}
-
-module.exports = basicAuth
diff --git a/functions/api/basicAuth.spec.js b/functions/api/basicAuth.spec.js
deleted file mode 100644
index 18e02a2a..00000000
--- a/functions/api/basicAuth.spec.js
+++ /dev/null
@@ -1,135 +0,0 @@
-'use strict';
-
-// basicAuth.js captures process.env at require time, so each describe
-// block resets modules and requires the module with the relevant env.
-
-const makeReq = (authHeader) => ({
- headers: { authorization: authHeader || '' },
-});
-
-const makeRes = () => ({
- status: jest.fn().mockReturnThis(),
- send: jest.fn().mockReturnThis(),
-});
-
-describe('functions/api/basicAuth', () => {
- let consoleInfoSpy;
-
- beforeEach(() => {
- consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
- });
-
- afterEach(() => {
- consoleInfoSpy.mockRestore();
- delete process.env.API_SERVICEUSER_USERNAME;
- delete process.env.API_SERVICEUSER_PASSWORD;
- });
-
- describe('when env vars are missing', () => {
- let basicAuth;
-
- beforeEach(() => {
- jest.resetModules();
- basicAuth = require('./basicAuth');
- });
-
- it('returns 401 when both env vars are absent', () => {
- const next = jest.fn();
- const res = makeRes();
- basicAuth(makeReq(), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(res.send).toHaveBeenCalledWith('Unauthorized');
- expect(next).not.toHaveBeenCalled();
- });
- });
-
- describe('when username is missing', () => {
- let basicAuth;
-
- beforeEach(() => {
- jest.resetModules();
- process.env.API_SERVICEUSER_PASSWORD = 'pass';
- basicAuth = require('./basicAuth');
- });
-
- it('returns 401', () => {
- const next = jest.fn();
- const res = makeRes();
- basicAuth(makeReq(), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(next).not.toHaveBeenCalled();
- });
- });
-
- describe('when password is missing', () => {
- let basicAuth;
-
- beforeEach(() => {
- jest.resetModules();
- process.env.API_SERVICEUSER_USERNAME = 'user';
- basicAuth = require('./basicAuth');
- });
-
- it('returns 401', () => {
- const next = jest.fn();
- const res = makeRes();
- basicAuth(makeReq(), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(next).not.toHaveBeenCalled();
- });
- });
-
- describe('with valid env vars', () => {
- let basicAuth;
-
- beforeEach(() => {
- jest.resetModules();
- process.env.API_SERVICEUSER_USERNAME = 'admin';
- process.env.API_SERVICEUSER_PASSWORD = 's3cret';
- basicAuth = require('./basicAuth');
- });
-
- it('calls next() when credentials are valid', () => {
- const next = jest.fn();
- const res = makeRes();
- const credentials = Buffer.from('admin:s3cret').toString('base64');
- basicAuth(makeReq(`Basic ${credentials}`), res, next);
- expect(next).toHaveBeenCalled();
- expect(res.status).not.toHaveBeenCalled();
- });
-
- it('returns 401 when password is wrong', () => {
- const next = jest.fn();
- const res = makeRes();
- const credentials = Buffer.from('admin:wrongpass').toString('base64');
- basicAuth(makeReq(`Basic ${credentials}`), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(next).not.toHaveBeenCalled();
- });
-
- it('returns 401 when username is wrong', () => {
- const next = jest.fn();
- const res = makeRes();
- const credentials = Buffer.from('wronguser:s3cret').toString('base64');
- basicAuth(makeReq(`Basic ${credentials}`), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(next).not.toHaveBeenCalled();
- });
-
- it('returns 401 when no Authorization header is provided', () => {
- const next = jest.fn();
- const res = makeRes();
- basicAuth(makeReq(''), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(next).not.toHaveBeenCalled();
- });
-
- it('returns 401 when auth type is Bearer instead of Basic', () => {
- const next = jest.fn();
- const res = makeRes();
- basicAuth(makeReq('Bearer sometoken'), res, next);
- expect(res.status).toHaveBeenCalledWith(401);
- expect(next).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/functions/api/customs/buildCustomsPayload.js b/functions/api/customs/buildCustomsPayload.js
new file mode 100644
index 00000000..a86bfe4c
--- /dev/null
+++ b/functions/api/customs/buildCustomsPayload.js
@@ -0,0 +1,146 @@
+const moment = require('moment')
+
+// Server-side reconstruction of the customs "prepopulated form" payload.
+//
+// This used to be built on the client and forwarded verbatim to the trusted
+// customs integration, which let any caller submit arbitrary declarations. The
+// payload is now built here from the stored movement, so the only client input
+// is a reference (movementType + movementKey); everything sent onward comes
+// from trusted database data.
+
+const TIMEZONE = 'Europe/Zurich'
+
+const MOVEMENT_PATHS = {
+ departure: 'departures',
+ arrival: 'arrivals',
+}
+
+const getCustomsAircraftType = (aircraftCategory) => {
+ if (['Hubschrauber', 'Eigenbauhubschrauber'].includes(aircraftCategory)) {
+ return 'helicopter'
+ }
+ return 'airplane'
+}
+
+const parseDuration = (duration) => {
+ const [hours, minutes] = String(duration || '').split(':')
+ return { hours: parseInt(hours, 10) || 0, minutes: parseInt(minutes, 10) || 0 }
+}
+
+// The movement stores a single ISO-UTC `dateTime`; the client presents it as a
+// local date + time (the aerodrome runs on Europe/Zurich). Reproduce that split
+// server-side so the customs payload matches what the client used to send.
+// Uses Intl (full-ICU, DST-aware) so no timezone dependency is required.
+const toLocalDateTimeParts = (isoUtc) => {
+ const date = new Date(isoUtc)
+ if (isNaN(date.getTime())) {
+ return { localDate: null, localTime: null }
+ }
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: TIMEZONE,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).formatToParts(date)
+ const get = (type) => {
+ const part = parts.find(p => p.type === type)
+ return part ? part.value : ''
+ }
+ // Some ICU builds render midnight as '24' with hour12:false; normalize it.
+ const hour = get('hour') === '24' ? '00' : get('hour')
+ return {
+ localDate: `${get('year')}-${get('month')}-${get('day')}`,
+ localTime: `${hour}:${get('minute')}`,
+ }
+}
+
+// Swiss-German short date (DD.MM.YYYY), matching the client's dates.formatDate.
+const formatDate = (localDate) => {
+ if (!localDate) {
+ return null
+ }
+ return moment(localDate, 'YYYY-MM-DD', true).locale('de-ch').format('L')
+}
+
+// Add the flight duration to the local departure time. Plain HH:mm arithmetic.
+const calculateArrivalTime = (departureTime, duration) => {
+ if (!departureTime) {
+ return null
+ }
+ const { hours, minutes } = parseDuration(duration)
+ return moment(departureTime, 'HH:mm').add(hours, 'hours').add(minutes, 'minutes').format('HH:mm')
+}
+
+const getDirectionDependingData = (movementType, movement, aerodrome, localTime) => {
+ if (movementType === 'departure') {
+ return {
+ departureTime: localTime,
+ arrivalCountry: aerodrome.country,
+ arrivalLocation: aerodrome.name,
+ arrivalTime: calculateArrivalTime(localTime, movement.duration),
+ }
+ }
+ return {
+ arrivalTime: localTime,
+ departureCountry: aerodrome.country,
+ departureLocation: aerodrome.name,
+ }
+}
+
+/**
+ * Build the customs payload for a stored movement.
+ * @returns the payload object, or null if the movement does not exist.
+ */
+const buildCustomsPayload = async (db, movementType, movementKey) => {
+ const path = MOVEMENT_PATHS[movementType]
+ if (!path) {
+ return null
+ }
+
+ const movementSnapshot = await db.ref(`/${path}`).child(movementKey).once('value')
+ if (!movementSnapshot.exists()) {
+ return null
+ }
+ const movement = movementSnapshot.val()
+
+ const icao = String(movement.location || '').toUpperCase()
+ const aerodromeSnapshot = await db.ref('/aerodromes').child(icao).once('value')
+ const aerodrome = aerodromeSnapshot.val() || {}
+
+ // The home aerodrome is taken from server config, never the client. It is the
+ // same identifier the customs integration is keyed by (also used as `?ad=`).
+ const customsSnapshot = await db.ref('/settings/customsDeclarationApp').once('value')
+ const customsSettings = customsSnapshot.val()
+ const aerodromeId = customsSettings && customsSettings.aerodrome
+ ? String(customsSettings.aerodrome).toLowerCase()
+ : null
+
+ const { localDate, localTime } = toLocalDateTimeParts(movement.dateTime)
+
+ return {
+ aerodromeId,
+ externalId: movementKey,
+ data: {
+ direction: movementType,
+ date: formatDate(localDate),
+ phone: movement.phone,
+ email: movement.email,
+ registration: movement.immatriculation,
+ mtow: movement.mtow,
+ aircraftType: getCustomsAircraftType(movement.aircraftCategory),
+ ...getDirectionDependingData(movementType, movement, aerodrome, localTime),
+ },
+ }
+}
+
+module.exports = {
+ buildCustomsPayload,
+ getCustomsAircraftType,
+ parseDuration,
+ calculateArrivalTime,
+ formatDate,
+ toLocalDateTimeParts,
+}
diff --git a/functions/api/customs/buildCustomsPayload.spec.js b/functions/api/customs/buildCustomsPayload.spec.js
new file mode 100644
index 00000000..bcbb6ef2
--- /dev/null
+++ b/functions/api/customs/buildCustomsPayload.spec.js
@@ -0,0 +1,196 @@
+const {
+ buildCustomsPayload,
+ getCustomsAircraftType,
+ parseDuration,
+ calculateArrivalTime,
+ formatDate,
+ toLocalDateTimeParts,
+} = require('./buildCustomsPayload')
+
+const makeSnapshot = (val) => ({
+ exists: () => val !== null && val !== undefined,
+ val: () => val,
+})
+
+// Minimal RTDB db mock keyed by path. Supports:
+// db.ref('/departures').child(key).once()
+// db.ref('/arrivals').child(key).once()
+// db.ref('/aerodromes').child(icao).once()
+// db.ref('/settings/customsDeclarationApp').once()
+const makeDb = ({ departures = {}, arrivals = {}, aerodromes = {}, customsSettings = null }) => ({
+ ref: (path) => {
+ if (path === '/departures') {
+ return { child: (k) => ({ once: async () => makeSnapshot(departures[k] ?? null) }) }
+ }
+ if (path === '/arrivals') {
+ return { child: (k) => ({ once: async () => makeSnapshot(arrivals[k] ?? null) }) }
+ }
+ if (path === '/aerodromes') {
+ return { child: (icao) => ({ once: async () => makeSnapshot(aerodromes[icao] ?? null) }) }
+ }
+ if (path === '/settings/customsDeclarationApp') {
+ return { once: async () => makeSnapshot(customsSettings) }
+ }
+ throw new Error(`unexpected ref path: ${path}`)
+ },
+})
+
+describe('functions/api/customs/buildCustomsPayload', () => {
+ describe('pure helpers', () => {
+ it('maps helicopter categories', () => {
+ expect(getCustomsAircraftType('Hubschrauber')).toBe('helicopter')
+ expect(getCustomsAircraftType('Eigenbauhubschrauber')).toBe('helicopter')
+ expect(getCustomsAircraftType('Flugzeug')).toBe('airplane')
+ })
+
+ it('parses duration', () => {
+ expect(parseDuration('01:30')).toEqual({ hours: 1, minutes: 30 })
+ expect(parseDuration('00:45')).toEqual({ hours: 0, minutes: 45 })
+ })
+
+ it('adds duration to departure time', () => {
+ expect(calculateArrivalTime('10:00', '01:30')).toBe('11:30')
+ expect(calculateArrivalTime('23:30', '01:00')).toBe('00:30')
+ })
+
+ it('returns null arrival time when the base time is missing', () => {
+ expect(calculateArrivalTime(null, '01:00')).toBeNull()
+ })
+
+ it('formats a local date as Swiss-German short date', () => {
+ expect(formatDate('2026-08-17')).toBe('17.08.2026')
+ expect(formatDate(null)).toBeNull()
+ })
+
+ it('splits an ISO-UTC dateTime into Europe/Zurich local parts (DST-aware)', () => {
+ // 2026-08-17 is CEST (UTC+2): 08:00Z -> 10:00 local.
+ expect(toLocalDateTimeParts('2026-08-17T08:00:00.000Z')).toEqual({
+ localDate: '2026-08-17',
+ localTime: '10:00',
+ })
+ // A winter date is CET (UTC+1): 08:00Z -> 09:00 local.
+ expect(toLocalDateTimeParts('2026-01-15T08:00:00.000Z')).toEqual({
+ localDate: '2026-01-15',
+ localTime: '09:00',
+ })
+ })
+
+ it('returns null parts for a missing/invalid dateTime', () => {
+ expect(toLocalDateTimeParts(undefined)).toEqual({ localDate: null, localTime: null })
+ expect(toLocalDateTimeParts('not-a-date')).toEqual({ localDate: null, localTime: null })
+ })
+ })
+
+ describe('buildCustomsPayload', () => {
+ const customsSettings = { aerodrome: 'LSZM', baseUrl: 'https://customs', accessToken: 't' }
+
+ it('builds a departure payload from the stored movement (server-side)', async () => {
+ const db = makeDb({
+ departures: {
+ 'mov-1': {
+ // Stored shape: a combined ISO-UTC dateTime + duration (NOT date/time).
+ dateTime: '2026-08-17T08:00:00.000Z', // CEST -> 10:00 local
+ duration: '01:30',
+ location: 'LFLY',
+ phone: '+41 79 000 00 00',
+ email: 'pilot@example.ch',
+ immatriculation: 'HBKLA',
+ mtow: 1100,
+ aircraftCategory: 'Flugzeug',
+ },
+ },
+ aerodromes: { LFLY: { country: 'FR', name: 'LYON CORBAS AIRFIELD' } },
+ customsSettings,
+ })
+
+ const payload = await buildCustomsPayload(db, 'departure', 'mov-1')
+
+ expect(payload).toEqual({
+ aerodromeId: 'lszm', // from server config, lowercased — never the client
+ externalId: 'mov-1', // server-derived from the movement key
+ data: {
+ direction: 'departure',
+ date: '17.08.2026', // derived from dateTime, not a raw (absent) date field
+ phone: '+41 79 000 00 00',
+ email: 'pilot@example.ch',
+ registration: 'HBKLA',
+ mtow: 1100,
+ aircraftType: 'airplane',
+ departureTime: '10:00',
+ arrivalCountry: 'FR',
+ arrivalLocation: 'LYON CORBAS AIRFIELD',
+ arrivalTime: '11:30',
+ },
+ })
+ })
+
+ it('builds an arrival payload with departure-origin fields', async () => {
+ const db = makeDb({
+ arrivals: {
+ 'arr-9': {
+ dateTime: '2026-08-17T12:00:00.000Z', // CEST -> 14:00 local
+ location: 'EDNY',
+ phone: '+49 1',
+ email: 'g@example.de',
+ immatriculation: 'DKABC',
+ mtow: 900,
+ aircraftCategory: 'Hubschrauber',
+ },
+ },
+ aerodromes: { EDNY: { country: 'DE', name: 'Friedrichshafen' } },
+ customsSettings,
+ })
+
+ const payload = await buildCustomsPayload(db, 'arrival', 'arr-9')
+
+ expect(payload.externalId).toBe('arr-9')
+ expect(payload.data).toMatchObject({
+ direction: 'arrival',
+ date: '17.08.2026',
+ aircraftType: 'helicopter',
+ arrivalTime: '14:00',
+ departureCountry: 'DE',
+ departureLocation: 'Friedrichshafen',
+ })
+ // Departure-direction-only fields must not be present on an arrival.
+ expect(payload.data.departureTime).toBeUndefined()
+ expect(payload.data.arrivalCountry).toBeUndefined()
+ })
+
+ it('returns null when the movement does not exist', async () => {
+ const db = makeDb({ departures: {}, customsSettings })
+ const payload = await buildCustomsPayload(db, 'departure', 'missing')
+ expect(payload).toBeNull()
+ })
+
+ it('returns null for an unknown movement type', async () => {
+ const db = makeDb({ customsSettings })
+ const payload = await buildCustomsPayload(db, 'taxi', 'mov-1')
+ expect(payload).toBeNull()
+ })
+
+ it('sets aerodromeId to null when customs is not configured', async () => {
+ const db = makeDb({
+ departures: { 'mov-2': { dateTime: '2026-08-17T07:00:00.000Z', duration: '00:30', location: 'LFLY', aircraftCategory: 'Flugzeug' } },
+ aerodromes: { LFLY: { country: 'FR', name: 'Lyon' } },
+ customsSettings: null,
+ })
+ const payload = await buildCustomsPayload(db, 'departure', 'mov-2')
+ expect(payload.aerodromeId).toBeNull()
+ })
+
+ it('tolerates a missing aerodrome record (undefined country/name)', async () => {
+ const db = makeDb({
+ departures: { 'mov-3': { dateTime: '2026-08-17T07:00:00.000Z', duration: '00:30', location: 'ZZZZ', aircraftCategory: 'Flugzeug' } },
+ aerodromes: {},
+ customsSettings,
+ })
+ const payload = await buildCustomsPayload(db, 'departure', 'mov-3')
+ expect(payload.data.arrivalCountry).toBeUndefined()
+ expect(payload.data.arrivalLocation).toBeUndefined()
+ // Date/time still resolve from dateTime even without the aerodrome record.
+ expect(payload.data.date).toBe('17.08.2026')
+ expect(payload.data.departureTime).toBe('09:00')
+ })
+ })
+})
diff --git a/functions/api/fbAuth.js b/functions/api/fbAuth.js
index 75ed748d..8c79e097 100644
--- a/functions/api/fbAuth.js
+++ b/functions/api/fbAuth.js
@@ -22,7 +22,10 @@ const fbAuth = async (req, res, next) => {
}
try {
- const decodedToken = await admin.auth().verifyIdToken(idToken);
+ // Pass checkRevoked=true so revoked sessions and disabled accounts are
+ // rejected immediately (matches the WebAuthn path in webauthnHelpers.js),
+ // rather than remaining valid until the ID token expires.
+ const decodedToken = await admin.auth().verifyIdToken(idToken, true);
const uid = decodedToken.uid;
console.log('Authenticated user:', uid);
@@ -56,7 +59,23 @@ const fbAdminAuth = async (req, res, next) => {
});
}
+// Shared-access sessions (guest / kiosk) authenticate with a fixed uid. Some
+// routes must stay off-limits to them — e.g. driving the trusted customs
+// integration — even though they are otherwise authenticated.
+const SHARED_SESSION_UIDS = ['guest', 'kiosk']
+
+const fbAuthExcludingShared = async (req, res, next) => {
+ await fbAuth(req, res, () => {
+ if (SHARED_SESSION_UIDS.includes(req.fbUserId)) {
+ console.info(`Shared session '${req.fbUserId}' rejected on restricted route. Returning 403 Forbidden.`)
+ return res.status(403).send('Forbidden: not available to shared sessions')
+ }
+ return next()
+ })
+}
+
module.exports = {
fbAuth,
- fbAdminAuth
+ fbAdminAuth,
+ fbAuthExcludingShared
}
diff --git a/functions/api/fbAuth.spec.js b/functions/api/fbAuth.spec.js
index 24f97ab7..8a3bd3fb 100644
--- a/functions/api/fbAuth.spec.js
+++ b/functions/api/fbAuth.spec.js
@@ -10,7 +10,7 @@ jest.mock('firebase-admin', () => ({
}));
const admin = require('firebase-admin');
-const { fbAuth, fbAdminAuth } = require('./fbAuth');
+const { fbAuth, fbAdminAuth, fbAuthExcludingShared } = require('./fbAuth');
describe('functions', () => {
describe('api/fbAuth', () => {
@@ -49,7 +49,7 @@ describe('functions', () => {
await fbAuth(req, res, next);
- expect(admin.auth().verifyIdToken).toHaveBeenCalledWith('valid-token');
+ expect(admin.auth().verifyIdToken).toHaveBeenCalledWith('valid-token', true);
expect(req.fbUserId).toBe('user123');
expect(req.fbUserEmail).toBe('user@test.com');
expect(next).toHaveBeenCalled();
@@ -65,6 +65,20 @@ describe('functions', () => {
expect(res.send).toHaveBeenCalledWith('Unauthorized');
expect(next).not.toHaveBeenCalled();
});
+
+ it('returns 401 when the token has been revoked', async () => {
+ req.headers.authorization = 'Bearer revoked-token';
+ const revokedError = new Error('Token revoked');
+ revokedError.code = 'auth/id-token-revoked';
+ admin.auth().verifyIdToken.mockRejectedValue(revokedError);
+
+ await fbAuth(req, res, next);
+
+ expect(admin.auth().verifyIdToken).toHaveBeenCalledWith('revoked-token', true);
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.send).toHaveBeenCalledWith('Unauthorized');
+ expect(next).not.toHaveBeenCalled();
+ });
});
describe('fbAdminAuth', () => {
@@ -100,5 +114,46 @@ describe('functions', () => {
expect(res.status).toHaveBeenCalledWith(500);
});
});
+
+ describe('fbAuthExcludingShared', () => {
+ it('calls next for a normal authenticated user', async () => {
+ req.headers.authorization = 'Bearer valid-token';
+ admin.auth().verifyIdToken.mockResolvedValue({ uid: 'user123', email: 'user@test.com' });
+
+ await fbAuthExcludingShared(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalledWith(403);
+ });
+
+ it('returns 403 for a guest shared session', async () => {
+ req.headers.authorization = 'Bearer valid-token';
+ admin.auth().verifyIdToken.mockResolvedValue({ uid: 'guest', email: undefined });
+
+ await fbAuthExcludingShared(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 for a kiosk shared session', async () => {
+ req.headers.authorization = 'Bearer valid-token';
+ admin.auth().verifyIdToken.mockResolvedValue({ uid: 'kiosk', email: undefined });
+
+ await fbAuthExcludingShared(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('returns 401 (from fbAuth) when unauthenticated, without reaching the shared check', async () => {
+ req.headers = {};
+
+ await fbAuthExcludingShared(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(next).not.toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/functions/api/index.js b/functions/api/index.js
index ea9f2275..5d7994af 100644
--- a/functions/api/index.js
+++ b/functions/api/index.js
@@ -3,23 +3,10 @@ const admin = require('firebase-admin')
const express = require('express')
const cors = require('cors')({origin: true, credentials: true})
const fetchAerodromeStatus = require('./fetchAerodromeStatus')
-const basicAuth = require('./basicAuth')
-const syncUsers = require('./syncUsers')
const fetchUserInvoiceRecipients = require('./fetchUserInvoiceRecipients')
const {fetchInvoices, fetchCheckouts, postPrepopulatedForm, isCustomsDeclarationAppAvailable} = require('./customs/fetchFromCustoms')
-const {fbAuth, fbAdminAuth} = require('./fbAuth')
-
-// The user-import (member management) endpoint is only relevant to projects with
-// member management enabled (currently lspv). The deploy workflow writes this
-// generated flag from the project's `memberManagement` config; it is absent in
-// local dev / tests, where the endpoint stays disabled. Fail closed: if the
-// flag is missing or false, the route is not registered at all (404).
-let memberManagementEnabled = false
-try {
- memberManagementEnabled = require('../member-management.generated.js')
-} catch (e) {
- if (e.code !== 'MODULE_NOT_FOUND') throw e
-}
+const {buildCustomsPayload} = require('./customs/buildCustomsPayload')
+const {fbAuth, fbAdminAuth, fbAuthExcludingShared} = require('./fbAuth')
const api = express()
@@ -37,25 +24,6 @@ api.get('(/api)?/aerodrome/status', async (req, res) => {
res.send(status)
})
-if (memberManagementEnabled) {
- api.post('(/api)?/users/import', basicAuth, async (req, res) => {
- try {
- const users = req.body.users
- if (!Array.isArray(users)) {
- return res.status(400).send('Invalid users format')
- }
-
- const db = admin.database()
- await syncUsers(db, users)
-
- res.status(200).send({ message: 'Users imported successfully' })
- } catch (e) {
- console.error('Failed to import users', e)
- res.status(500).send({ error: 'Failed to import users' })
- }
- })
-}
-
api.get('(/api)?/customs/invoices', fbAdminAuth, async (req, res) => {
try {
const db = admin.database()
@@ -80,11 +48,32 @@ api.get('(/api)?/customs/checkouts', fbAdminAuth, async (req, res) => {
}
})
-api.post('(/api)?/customs/prepopulated-forms', fbAuth, async (req, res) => {
+api.post('(/api)?/customs/prepopulated-forms', fbAuthExcludingShared, async (req, res) => {
try {
+ const { movementType, movementKey } = req.body || {}
+
+ // The only accepted input is a reference to an existing movement. The
+ // outbound payload is built server-side from stored data (see
+ // buildCustomsPayload), so the caller cannot inject arbitrary content into
+ // the trusted customs integration.
+ if ((movementType !== 'departure' && movementType !== 'arrival') || typeof movementKey !== 'string' || !movementKey) {
+ return res.status(400).send({ error: 'movementType (departure|arrival) and movementKey are required' })
+ }
+
const db = admin.database()
- const formData = req.body
- const result = await postPrepopulatedForm(db, formData)
+
+ const payload = await buildCustomsPayload(db, movementType, movementKey)
+ if (!payload) {
+ return res.status(404).send({ error: 'Movement not found' })
+ }
+
+ console.info(`Customs prepopulated form requested by ${req.fbUserId} for ${movementType}/${movementKey}`)
+
+ const result = await postPrepopulatedForm(db, payload)
+ if (!result) {
+ return res.status(503).send({ error: 'Customs declaration app not configured' })
+ }
+
res.status(200).send(result)
} catch (e) {
console.error('Failed to post prepopulated form to customs', e)
@@ -115,5 +104,3 @@ api.get('(/api)?/users/me/invoice-recipients', fbAuth, async (req, res) => {
})
module.exports = onRequest({ region: 'europe-west1' }, api)
-// Exposed for tests (route registration depends on the member-management flag).
-module.exports.app = api
diff --git a/functions/api/index.spec.js b/functions/api/index.spec.js
deleted file mode 100644
index 37da5f72..00000000
--- a/functions/api/index.spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-'use strict';
-
-// The /users/import (member management) route is registered only when the
-// generated member-management flag is truthy. The flag file is absent in tests,
-// so it is mocked per-case (virtual module).
-
-const FLAG_PATH = '../member-management.generated.js';
-
-const loadApi = (flag) => {
- jest.resetModules();
- jest.doMock(FLAG_PATH, () => flag, { virtual: true });
- jest.doMock('firebase-functions/v2/https', () => ({
- onRequest: (opts, app) => app,
- }));
- return require('./index').app;
-};
-
-const hasUsersImportRoute = (app) =>
- app._router.stack.some(
- layer => layer.route && String(layer.route.path).includes('/users/import')
- );
-
-describe('functions', () => {
- describe('api/index member-management gating', () => {
- afterEach(() => {
- jest.dontMock(FLAG_PATH);
- jest.resetModules();
- });
-
- it('does not register /users/import when member management is disabled', () => {
- const app = loadApi(false);
- expect(hasUsersImportRoute(app)).toBe(false);
- });
-
- it('does not register /users/import when the flag file is missing', () => {
- // Simulate the generated file being absent (MODULE_NOT_FOUND -> disabled).
- jest.resetModules();
- jest.doMock('firebase-functions/v2/https', () => ({ onRequest: (opts, app) => app }));
- const app = require('./index').app;
- expect(hasUsersImportRoute(app)).toBe(false);
- });
-
- it('registers /users/import when member management is enabled', () => {
- const app = loadApi(true);
- expect(hasUsersImportRoute(app)).toBe(true);
- });
- });
-});
diff --git a/functions/api/syncUsers.js b/functions/api/syncUsers.js
deleted file mode 100644
index 53e0e381..00000000
--- a/functions/api/syncUsers.js
+++ /dev/null
@@ -1,86 +0,0 @@
-'use strict';
-
-const syncUsers = async (firebase, users) => {
- const usersRef = firebase.ref('users')
-
- const currentUsers = await fetchCurrentUsers(usersRef)
- const importedUsers = mapUsersByMemberNr(users)
-
- const {updates, deletes} = buildUpdatesAndDeletes(currentUsers, importedUsers)
- await applyUpdatesAndDeletes(usersRef, updates, deletes)
-}
-
-async function fetchCurrentUsers(usersRef) {
- const snapshot = await usersRef.once('value')
- const users = snapshot.val() || {}
-
- // Transform users to a map where memberNr is the key for easy lookup
- const userMap = {}
- Object.keys(users).forEach(id => {
- const user = users[id]
- if (user.memberNr) {
- userMap[user.memberNr] = {...user, id}
- }
- })
- return userMap
-}
-
-function mapUsersByMemberNr(users) {
- const userMap = {}
- users.forEach(user => {
- if (user.memberNr) {
- userMap[user.memberNr] = user
- }
- })
- return userMap
-}
-
-function buildUpdatesAndDeletes(currentUsers, importedUsers) {
- const updates = {}
- const deletes = []
-
- // Add new users and update existing ones
- Object.keys(importedUsers).forEach(memberNr => {
- const importedUser = importedUsers[memberNr]
- if (currentUsers[memberNr]) {
- // Existing user - update by ID
- updates[currentUsers[memberNr].id] = importedUser
- } else {
- // New user - add to the list of new users
- updates[`new-${memberNr}`] = importedUser // Placeholder for new
- }
- })
-
- // Delete users that are not present in the imported array
- Object.keys(currentUsers).forEach(memberNr => {
- if (!importedUsers.hasOwnProperty(memberNr)) {
- deletes.push(currentUsers[memberNr].id)
- }
- })
-
- return {updates, deletes}
-}
-
-async function applyUpdatesAndDeletes(usersRef, updates, deletes) {
- // Apply new and updated users
- const batch = {}
- for (const key in updates) {
- if (key.startsWith('new-')) {
- // Generate new ID for new user
- await usersRef.push(updates[key])
- } else {
- // Update existing user
- batch[key] = updates[key]
- }
- }
-
- // Remove users
- deletes.forEach(async id => {
- await usersRef.child(id).remove()
- })
-
- // Apply updates
- await usersRef.update(batch)
-}
-
-module.exports = syncUsers
diff --git a/functions/api/syncUsers.spec.js b/functions/api/syncUsers.spec.js
deleted file mode 100644
index 3345d77a..00000000
--- a/functions/api/syncUsers.spec.js
+++ /dev/null
@@ -1,225 +0,0 @@
-'use strict';
-
-const syncUsers = require('./syncUsers');
-
-describe('functions/api/syncUsers', () => {
- const buildFirebaseMock = (existingUsersObj = {}) => {
- const mockPush = jest.fn().mockResolvedValue({ key: 'new-generated-key' });
- const mockRemove = jest.fn().mockResolvedValue();
- const mockUpdate = jest.fn().mockResolvedValue();
- const mockOnce = jest.fn().mockResolvedValue({
- val: () => existingUsersObj
- });
- const mockChild = jest.fn(() => ({ remove: mockRemove }));
-
- const usersRef = {
- once: mockOnce,
- push: mockPush,
- update: mockUpdate,
- child: mockChild
- };
-
- const firebase = {
- ref: jest.fn(() => usersRef)
- };
-
- return { firebase, usersRef, mockPush, mockRemove, mockUpdate, mockChild };
- };
-
- describe('mapUsersByMemberNr', () => {
- it('adds new user when memberNr not in current users', async () => {
- const { firebase, mockPush } = buildFirebaseMock({});
-
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'Alice', lastname: 'Smith' }
- ]);
-
- expect(mockPush).toHaveBeenCalledWith(
- expect.objectContaining({ memberNr: '001', firstname: 'Alice' })
- );
- });
-
- it('ignores imported users without memberNr', async () => {
- const { firebase, mockPush, mockUpdate } = buildFirebaseMock({});
-
- await syncUsers(firebase, [
- { firstname: 'NoMember', lastname: 'User' } // no memberNr
- ]);
-
- expect(mockPush).not.toHaveBeenCalled();
- });
- });
-
- describe('fetchCurrentUsers', () => {
- it('builds user map keyed by memberNr from database snapshot', async () => {
- const existingUsers = {
- 'firebase-id-1': { memberNr: '001', firstname: 'Alice' },
- 'firebase-id-2': { memberNr: '002', firstname: 'Bob' }
- };
- const { firebase, mockUpdate } = buildFirebaseMock(existingUsers);
-
- // Import same users - should result in updates (same memberNr)
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'Alice Updated' },
- { memberNr: '002', firstname: 'Bob Updated' }
- ]);
-
- // Should update by firebase ID (not push new ones)
- expect(mockUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- 'firebase-id-1': expect.objectContaining({ memberNr: '001', firstname: 'Alice Updated' }),
- 'firebase-id-2': expect.objectContaining({ memberNr: '002', firstname: 'Bob Updated' })
- })
- );
- });
-
- it('skips existing users without memberNr during lookup build', async () => {
- // Users without memberNr in the database are excluded from the map
- const existingUsers = {
- 'firebase-id-1': { firstname: 'NoMember' } // no memberNr
- };
- const { firebase, mockPush } = buildFirebaseMock(existingUsers);
-
- // New import with memberNr - should push since existing user has no memberNr for lookup
- await syncUsers(firebase, [
- { memberNr: '999', firstname: 'New User' }
- ]);
-
- expect(mockPush).toHaveBeenCalledWith(
- expect.objectContaining({ memberNr: '999' })
- );
- });
-
- it('handles null database value (empty database)', async () => {
- const mockOnce = jest.fn().mockResolvedValue({ val: () => null });
- const mockPush = jest.fn().mockResolvedValue({});
- const mockUpdate = jest.fn().mockResolvedValue();
- const mockChild = jest.fn(() => ({ remove: jest.fn() }));
-
- const usersRef = { once: mockOnce, push: mockPush, update: mockUpdate, child: mockChild };
- const firebase = { ref: jest.fn(() => usersRef) };
-
- await syncUsers(firebase, [{ memberNr: '001', firstname: 'Alice' }]);
-
- expect(mockPush).toHaveBeenCalled();
- });
- });
-
- describe('buildUpdatesAndDeletes', () => {
- it('updates existing user by firebase ID', async () => {
- const existingUsers = {
- 'fb-id-1': { memberNr: '001', firstname: 'Alice', lastname: 'Old' }
- };
- const { firebase, mockUpdate } = buildFirebaseMock(existingUsers);
-
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'Alice', lastname: 'New' }
- ]);
-
- const updateArg = mockUpdate.mock.calls[0][0];
- expect(updateArg['fb-id-1']).toEqual(
- expect.objectContaining({ memberNr: '001', lastname: 'New' })
- );
- });
-
- it('creates placeholder key for new user', async () => {
- const { firebase, mockUpdate, mockPush } = buildFirebaseMock({});
-
- await syncUsers(firebase, [
- { memberNr: '123', firstname: 'New User' }
- ]);
-
- // New user should be pushed (not in batch update)
- expect(mockPush).toHaveBeenCalledWith(
- expect.objectContaining({ memberNr: '123' })
- );
- });
-
- it('deletes users not in imported list', async () => {
- const existingUsers = {
- 'fb-id-old': { memberNr: '999', firstname: 'OldUser' }
- };
- const { firebase, mockChild, mockRemove } = buildFirebaseMock(existingUsers);
-
- // Import does NOT include memberNr 999
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'NewUser' }
- ]);
-
- expect(mockChild).toHaveBeenCalledWith('fb-id-old');
- expect(mockRemove).toHaveBeenCalled();
- });
-
- it('handles empty imported users list (deletes all existing)', async () => {
- const existingUsers = {
- 'fb-id-1': { memberNr: '001', firstname: 'Alice' }
- };
- const { firebase, mockChild, mockRemove } = buildFirebaseMock(existingUsers);
-
- await syncUsers(firebase, []);
-
- expect(mockChild).toHaveBeenCalledWith('fb-id-1');
- expect(mockRemove).toHaveBeenCalled();
- });
-
- it('handles empty existing users (adds all imported)', async () => {
- const { firebase, mockPush } = buildFirebaseMock({});
-
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'Alice' },
- { memberNr: '002', firstname: 'Bob' }
- ]);
-
- expect(mockPush).toHaveBeenCalledTimes(2);
- });
- });
-
- describe('applyUpdatesAndDeletes', () => {
- it('calls update with batch of existing user updates', async () => {
- const existingUsers = {
- 'fb-id-1': { memberNr: '001', firstname: 'Alice' },
- 'fb-id-2': { memberNr: '002', firstname: 'Bob' }
- };
- const { firebase, mockUpdate } = buildFirebaseMock(existingUsers);
-
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'Alice v2' },
- { memberNr: '002', firstname: 'Bob v2' }
- ]);
-
- expect(mockUpdate).toHaveBeenCalledTimes(1);
- const batchArg = mockUpdate.mock.calls[0][0];
- expect(batchArg['fb-id-1']).toBeDefined();
- expect(batchArg['fb-id-2']).toBeDefined();
- });
-
- it('calls usersRef.ref with "users" path', async () => {
- const { firebase } = buildFirebaseMock({});
-
- await syncUsers(firebase, []);
-
- expect(firebase.ref).toHaveBeenCalledWith('users');
- });
-
- it('handles mix of new and existing users', async () => {
- const existingUsers = {
- 'fb-id-1': { memberNr: '001', firstname: 'Alice' }
- };
- const { firebase, mockPush, mockUpdate } = buildFirebaseMock(existingUsers);
-
- await syncUsers(firebase, [
- { memberNr: '001', firstname: 'Alice Updated' }, // existing
- { memberNr: '002', firstname: 'Bob New' } // new
- ]);
-
- // Existing gets updated via batch
- const batchArg = mockUpdate.mock.calls[0][0];
- expect(batchArg['fb-id-1']).toBeDefined();
-
- // New gets pushed
- expect(mockPush).toHaveBeenCalledWith(
- expect.objectContaining({ memberNr: '002' })
- );
- });
- });
-});
diff --git a/functions/auth/cleanupExpiredSignInCodes.js b/functions/auth/cleanupExpiredSignInCodes.js
index f73269eb..e5c3066c 100644
--- a/functions/auth/cleanupExpiredSignInCodes.js
+++ b/functions/auth/cleanupExpiredSignInCodes.js
@@ -43,5 +43,23 @@ exports.cleanupExpiredSignInCodes = onSchedule(
await rateLimitsRef.update(updates);
}
}
+
+ // Static-login per-IP throttle entries share the same windowed shape; prune
+ // those whose window has passed so the node cannot grow unbounded.
+ const staticRateLimitsRef = db.ref('/staticAuthRateLimits');
+ const staticRateLimitsSnapshot = await staticRateLimitsRef.once('value');
+
+ if (staticRateLimitsSnapshot.exists()) {
+ const updates = {};
+ staticRateLimitsSnapshot.forEach(child => {
+ const val = child.val();
+ if (now - val.windowStart > RATE_WINDOW_MS) {
+ updates[child.key] = null;
+ }
+ });
+ if (Object.keys(updates).length > 0) {
+ await staticRateLimitsRef.update(updates);
+ }
+ }
}
);
diff --git a/functions/auth/cleanupExpiredSignInCodes.spec.js b/functions/auth/cleanupExpiredSignInCodes.spec.js
index e1406fd2..44e2609c 100644
--- a/functions/auth/cleanupExpiredSignInCodes.spec.js
+++ b/functions/auth/cleanupExpiredSignInCodes.spec.js
@@ -5,6 +5,7 @@ describe('functions', () => {
let capturedOptions;
let mockCodesRef;
let mockRateLimitsRef;
+ let mockStaticRateLimitsRef;
const now = Date.now();
@@ -23,10 +24,18 @@ describe('functions', () => {
once: jest.fn().mockResolvedValue(emptySnapshot),
update: jest.fn().mockResolvedValue(undefined),
};
+ mockStaticRateLimitsRef = {
+ once: jest.fn().mockResolvedValue(emptySnapshot),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const refs = {
+ '/signInRateLimits': mockRateLimitsRef,
+ '/staticAuthRateLimits': mockStaticRateLimitsRef,
+ };
mockAdmin = {
database: jest.fn().mockReturnValue({
- ref: jest.fn(path => path === '/signInRateLimits' ? mockRateLimitsRef : mockCodesRef)
+ ref: jest.fn(path => refs[path] || mockCodesRef)
})
};
@@ -147,6 +156,17 @@ describe('functions', () => {
expect(mockRateLimitsRef.update).not.toHaveBeenCalled();
});
+ it('prunes static-login throttle entries older than the window', async () => {
+ mockStaticRateLimitsRef.once.mockResolvedValue(makeSnapshot([
+ { key: 's1', val: { windowStart: now - (61 * 60 * 1000), count: 10 } }, // stale
+ { key: 's2', val: { windowStart: now - (5 * 60 * 1000), count: 3 } }, // recent
+ ]));
+
+ await capturedHandler();
+
+ expect(mockStaticRateLimitsRef.update).toHaveBeenCalledWith({ s1: null });
+ });
+
it('is scheduled to run every 60 minutes', () => {
expect(capturedOptions.schedule).toBe('every 60 minutes');
});
diff --git a/functions/auth/cleanupExpiredWebauthnChallenges.js b/functions/auth/cleanupExpiredWebauthnChallenges.js
index 7c1ba440..1c94b0b1 100644
--- a/functions/auth/cleanupExpiredWebauthnChallenges.js
+++ b/functions/auth/cleanupExpiredWebauthnChallenges.js
@@ -8,20 +8,23 @@ exports.cleanupExpiredWebauthnChallenges = onSchedule(
async () => {
const db = admin.database();
const ref = db.ref('/webauthnChallenges');
- const snapshot = await ref.once('value');
+ const now = Date.now();
+
+ // Read only the expired records via the `expiry` index, rather than scanning
+ // the whole node. `endAt(now)` returns records ordered up to `now`, which
+ // covers numeric expiries in the past and any missing/null expiry (those
+ // sort first); all of them are stale and should be removed. This keeps the
+ // cleanup cost proportional to the expired set, not the total node size —
+ // important when the (public) options endpoints are being flooded.
+ const snapshot = await ref.orderByChild('expiry').endAt(now).once('value');
if (!snapshot.exists()) {
return;
}
const updates = {};
- const now = Date.now();
-
snapshot.forEach(child => {
- const val = child.val() || {};
- if (typeof val.expiry !== 'number' || val.expiry <= now) {
- updates[child.key] = null;
- }
+ updates[child.key] = null;
});
if (Object.keys(updates).length > 0) {
diff --git a/functions/auth/cleanupExpiredWebauthnChallenges.spec.js b/functions/auth/cleanupExpiredWebauthnChallenges.spec.js
index 5173dbcd..95ef9d0a 100644
--- a/functions/auth/cleanupExpiredWebauthnChallenges.spec.js
+++ b/functions/auth/cleanupExpiredWebauthnChallenges.spec.js
@@ -12,7 +12,13 @@ describe('functions', () => {
capturedHandler = null;
capturedOptions = null;
+ // The cleanup reads only expired records via an indexed range query:
+ // ref.orderByChild('expiry').endAt(now).once('value')
+ // orderByChild/endAt are chainable (return the same ref); once resolves
+ // the (already-filtered) snapshot.
mockChallengesRef = {
+ orderByChild: jest.fn().mockReturnThis(),
+ endAt: jest.fn().mockReturnThis(),
once: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
};
@@ -34,56 +40,51 @@ describe('functions', () => {
require('./cleanupExpiredWebauthnChallenges');
});
+ // The snapshot represents what the `endAt(now)` query returned — i.e. the
+ // already-expired records. The handler deletes exactly these.
const makeSnapshot = (entries) => ({
exists: () => entries.length > 0,
forEach: (cb) => entries.forEach(({ key, val }) => cb({ key, val: () => val })),
});
- it('does nothing when no challenges exist', async () => {
+ it('queries the expiry index for records up to now', async () => {
mockChallengesRef.once.mockResolvedValue(makeSnapshot([]));
await capturedHandler();
- expect(mockChallengesRef.update).not.toHaveBeenCalled();
- });
- it('deletes expired challenges', async () => {
- mockChallengesRef.once.mockResolvedValue(makeSnapshot([
- { key: 'k1', val: { expiry: now - 1000 } },
- { key: 'k2', val: { expiry: now - 5000 } },
- ]));
- await capturedHandler();
- expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k2: null });
+ expect(mockChallengesRef.orderByChild).toHaveBeenCalledWith('expiry');
+ const endAtArg = mockChallengesRef.endAt.mock.calls[0][0];
+ expect(typeof endAtArg).toBe('number');
+ // Bound is "now" — within a small window of the test clock.
+ expect(Math.abs(endAtArg - now)).toBeLessThan(60000);
});
- it('keeps non-expired challenges', async () => {
- mockChallengesRef.once.mockResolvedValue(makeSnapshot([
- { key: 'k1', val: { expiry: now + 60000 } },
- { key: 'k2', val: { expiry: now + 600000 } },
- ]));
+ it('does nothing when no expired challenges are returned', async () => {
+ mockChallengesRef.once.mockResolvedValue(makeSnapshot([]));
await capturedHandler();
expect(mockChallengesRef.update).not.toHaveBeenCalled();
});
- it('only deletes expired when mixed with fresh', async () => {
+ it('deletes every record the expired-query returns', async () => {
mockChallengesRef.once.mockResolvedValue(makeSnapshot([
{ key: 'k1', val: { expiry: now - 1000 } },
- { key: 'k2', val: { expiry: now + 60000 } },
- { key: 'k3', val: { expiry: now - 5000 } },
+ { key: 'k2', val: { expiry: now - 5000 } },
]));
await capturedHandler();
- expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k3: null });
+ expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k2: null });
});
- it('deletes records with missing or non-numeric expiry', async () => {
+ it('removes stale records with missing expiry (returned by the range query)', async () => {
+ // endAt(now) also returns records whose `expiry` is missing/null (they
+ // sort before numbers); they are stale and get deleted.
mockChallengesRef.once.mockResolvedValue(makeSnapshot([
{ key: 'k1', val: {} },
- { key: 'k2', val: { expiry: 'not-a-number' } },
- { key: 'k3', val: { expiry: now + 60000 } },
+ { key: 'k2', val: { expiry: now - 1 } },
]));
await capturedHandler();
expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k2: null });
});
- it('is scheduled to run every 60 minutes', () => {
+ it('runs hourly', () => {
expect(capturedOptions.schedule).toBe('every 60 minutes');
});
});
diff --git a/functions/auth/emailTemplates.js b/functions/auth/emailTemplates.js
index 2b23eb4c..469f736b 100644
--- a/functions/auth/emailTemplates.js
+++ b/functions/auth/emailTemplates.js
@@ -9,28 +9,59 @@ const replacePlaceholders = (content, replacements) => {
});
};
+// airportName and themeColor come from the (public) request body and are
+// interpolated into the sign-in email HTML, so they must be neutralized before
+// insertion to prevent HTML/CSS injection (e.g. attacker-authored markup or
+// phishing links in a mail sent from the trusted Flightbox sender).
+const escapeHtml = value =>
+ String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+// Accept only a hex colour (#rgb / #rrggbb / #rrggbbaa) or a plain CSS colour
+// keyword; anything else falls back to a safe default so it cannot break out of
+// the style attribute or inject CSS.
+const SAFE_COLOR_REGEX = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$/;
+const DEFAULT_THEME_COLOR = '#000000';
+
+const sanitizeThemeColor = color =>
+ (typeof color === 'string' && SAFE_COLOR_REGEX.test(color)) ? color : DEFAULT_THEME_COLOR;
+
const readTemplate = (templateName, format) => {
const templatePath = path.join(__dirname, 'templates', `${templateName}.${format}`);
return fs.readFileSync(templatePath, 'utf8');
};
const getSignInEmailContent = ({ signInCode, airportName, themeColor, language }) => {
- const replacements = {
- signInCode,
- airportName,
- themeColor
- };
-
const templateName = language === 'en' ? 'signin_en' : 'signin';
const subject = language === 'en' ? 'Sign in to Flightbox' : 'Bei Flightbox anmelden';
const htmlTemplate = readTemplate(templateName, 'html');
const textTemplate = readTemplate(templateName, 'txt');
+ const safeColor = sanitizeThemeColor(themeColor);
+ const name = airportName === undefined || airportName === null ? '' : airportName;
+
+ // HTML output: escape the free-text name and use the validated colour.
+ const htmlReplacements = {
+ signInCode,
+ airportName: escapeHtml(name),
+ themeColor: safeColor
+ };
+ // Plain-text output: no markup context, so raw text is fine.
+ const textReplacements = {
+ signInCode,
+ airportName: name,
+ themeColor: safeColor
+ };
+
return {
subject,
- html: replacePlaceholders(htmlTemplate, replacements),
- text: replacePlaceholders(textTemplate, replacements)
+ html: replacePlaceholders(htmlTemplate, htmlReplacements),
+ text: replacePlaceholders(textTemplate, textReplacements)
};
};
diff --git a/functions/auth/emailTemplates.spec.js b/functions/auth/emailTemplates.spec.js
index 8e3fd6be..85314748 100644
--- a/functions/auth/emailTemplates.spec.js
+++ b/functions/auth/emailTemplates.spec.js
@@ -72,6 +72,56 @@ describe('functions', () => {
expect(result.html).not.toContain('{{themeColor}}');
});
+ it('escapes HTML in airportName to prevent injection', () => {
+ const result = getSignInEmailContent({
+ signInCode: '123456',
+ airportName: '',
+ themeColor: '#003863'
+ });
+ expect(result.html).not.toContain(''
+ });
+ expect(result.html).toContain('#000000');
+ expect(result.html).not.toContain('