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('