payment-kit 1.29.0 → 1.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/dev.ts +41 -2
- package/api/hono.d.ts +42 -0
- package/api/node-sqlite.d.ts +12 -0
- package/api/src/bootstrap.ts +36 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +27 -24
- package/api/src/crons/metering-subscription-detection.ts +1 -1
- package/api/src/crons/overdue-detection.ts +2 -2
- package/api/src/crons/retry-pending-events.ts +6 -0
- package/api/src/index.ts +22 -161
- package/api/src/integrations/app-store/client.ts +3 -4
- package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
- package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +21 -7
- package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
- package/api/src/integrations/google-play/handlers/voided.ts +2 -2
- package/api/src/integrations/google-play/verify.ts +3 -2
- package/api/src/integrations/iap-reconcile.ts +3 -5
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
- package/api/src/libs/archive/query.ts +19 -0
- package/api/src/libs/audit.ts +61 -4
- package/api/src/libs/auth.ts +99 -38
- package/api/src/libs/context.ts +78 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- package/api/src/libs/drivers/auth-storage.ts +118 -0
- package/api/src/libs/drivers/cron.ts +264 -0
- package/api/src/libs/drivers/db.ts +170 -0
- package/api/src/libs/drivers/identity.ts +81 -0
- package/api/src/libs/drivers/index.ts +40 -0
- package/api/src/libs/drivers/locks.ts +226 -0
- package/api/src/libs/drivers/migrate-runner.ts +70 -0
- package/api/src/libs/drivers/queue.ts +104 -0
- package/api/src/libs/drivers/secrets.ts +194 -0
- package/api/src/libs/env.ts +170 -54
- package/api/src/libs/exchange-rate/service.ts +7 -6
- package/api/src/libs/http-fetch-adapter.ts +50 -0
- package/api/src/libs/invoice.ts +1 -1
- package/api/src/libs/lock.ts +51 -47
- package/api/src/libs/logger.ts +48 -8
- package/api/src/libs/notification/index.ts +1 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
- package/api/src/libs/overdraft-protection.ts +1 -1
- package/api/src/libs/payout.ts +1 -1
- package/api/src/libs/queue/index.ts +259 -52
- package/api/src/libs/queue/runtime.ts +175 -0
- package/api/src/libs/resource.ts +3 -3
- package/api/src/libs/secrets.ts +38 -0
- package/api/src/libs/session.ts +3 -2
- package/api/src/libs/subscription.ts +5 -5
- package/api/src/libs/tenant.ts +92 -0
- package/api/src/libs/url.ts +3 -3
- package/api/src/libs/util.ts +21 -13
- package/api/src/middlewares/hono/cdn.ts +63 -0
- package/api/src/middlewares/hono/context.ts +73 -0
- package/api/src/middlewares/hono/csrf.ts +72 -0
- package/api/src/middlewares/hono/fallback.ts +194 -0
- package/api/src/middlewares/hono/pipeline.ts +73 -0
- package/api/src/middlewares/hono/resource-mount.ts +42 -0
- package/api/src/middlewares/hono/resource.ts +63 -0
- package/api/src/middlewares/hono/security.ts +214 -0
- package/api/src/middlewares/hono/session.ts +114 -0
- package/api/src/middlewares/hono/xss.ts +61 -0
- package/api/src/queues/auto-recharge.ts +12 -10
- package/api/src/queues/checkout-session.ts +17 -12
- package/api/src/queues/credit-consume.ts +40 -36
- package/api/src/queues/credit-grant.ts +25 -18
- package/api/src/queues/credit-reconciliation.ts +7 -5
- package/api/src/queues/discount-status.ts +9 -6
- package/api/src/queues/event.ts +12 -4
- package/api/src/queues/exchange-rate-health.ts +49 -30
- package/api/src/queues/invoice.ts +18 -15
- package/api/src/queues/notification.ts +14 -7
- package/api/src/queues/payment.ts +41 -28
- package/api/src/queues/payout.ts +9 -5
- package/api/src/queues/refund.ts +18 -12
- package/api/src/queues/subscription.ts +83 -53
- package/api/src/queues/token-transfer.ts +15 -10
- package/api/src/queues/usage-record.ts +8 -5
- package/api/src/queues/vendors/commission.ts +7 -5
- package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
- package/api/src/queues/vendors/fulfillment.ts +4 -2
- package/api/src/queues/vendors/return-processor.ts +5 -3
- package/api/src/queues/vendors/return-scanner.ts +5 -4
- package/api/src/queues/vendors/status-check.ts +10 -7
- package/api/src/queues/webhook.ts +60 -32
- package/api/src/routes/connect/shared.ts +1 -2
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
- package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
- package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
- package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
- package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
- package/api/src/routes/hono/credit-tokens.ts +43 -0
- package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
- package/api/src/routes/{customers.ts → hono/customers.ts} +193 -223
- package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
- package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
- package/api/src/routes/{events.ts → hono/events.ts} +107 -71
- package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
- package/api/src/routes/hono/exchange-rates.ts +77 -0
- package/api/src/routes/hono/index.ts +115 -0
- package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
- package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
- package/api/src/routes/hono/integrations/stripe.ts +74 -0
- package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
- package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
- package/api/src/routes/hono/meters.ts +288 -0
- package/api/src/routes/hono/passports.ts +73 -0
- package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
- package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
- package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
- package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
- package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
- package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
- package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
- package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
- package/api/src/routes/{products.ts → hono/products.ts} +172 -159
- package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
- package/api/src/routes/hono/redirect.ts +24 -0
- package/api/src/routes/{refunds.ts → hono/refunds.ts} +96 -80
- package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
- package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
- package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
- package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
- package/api/src/routes/hono/tool.ts +69 -0
- package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
- package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
- package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
- package/api/src/routes/hono/webhook-endpoints.ts +126 -0
- package/api/src/service.ts +667 -0
- package/api/src/store/migrations/20230911-seeding.ts +2 -1
- package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
- package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
- package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
- package/api/src/store/models/auto-recharge-config.ts +22 -10
- package/api/src/store/models/checkout-session.ts +15 -14
- package/api/src/store/models/coupon.ts +29 -20
- package/api/src/store/models/credit-grant.ts +38 -29
- package/api/src/store/models/credit-transaction.ts +32 -21
- package/api/src/store/models/customer.ts +19 -17
- package/api/src/store/models/discount.ts +11 -2
- package/api/src/store/models/entitlement-grant.ts +21 -9
- package/api/src/store/models/entitlement-product.ts +21 -9
- package/api/src/store/models/entitlement.ts +19 -10
- package/api/src/store/models/event.ts +18 -9
- package/api/src/store/models/exchange-rate-provider.ts +17 -4
- package/api/src/store/models/invoice-item.ts +18 -9
- package/api/src/store/models/invoice.ts +16 -8
- package/api/src/store/models/meter-event.ts +27 -9
- package/api/src/store/models/meter.ts +31 -22
- package/api/src/store/models/payment-currency.ts +25 -8
- package/api/src/store/models/payment-intent.ts +15 -6
- package/api/src/store/models/payment-link.ts +15 -6
- package/api/src/store/models/payment-method.ts +38 -22
- package/api/src/store/models/payment-stat.ts +18 -9
- package/api/src/store/models/payout.ts +15 -6
- package/api/src/store/models/price-quote.ts +17 -8
- package/api/src/store/models/price.ts +24 -12
- package/api/src/store/models/pricing-table.ts +29 -20
- package/api/src/store/models/product-vendor.ts +20 -10
- package/api/src/store/models/product.ts +15 -6
- package/api/src/store/models/promotion-code.ts +14 -6
- package/api/src/store/models/refund.ts +15 -6
- package/api/src/store/models/revenue-snapshot.ts +21 -9
- package/api/src/store/models/setting.ts +18 -9
- package/api/src/store/models/setup-intent.ts +36 -27
- package/api/src/store/models/subscription-item.ts +21 -9
- package/api/src/store/models/subscription-schedule.ts +21 -9
- package/api/src/store/models/subscription.ts +21 -10
- package/api/src/store/models/tax-rate.ts +29 -21
- package/api/src/store/models/usage-record.ts +11 -2
- package/api/src/store/models/webhook-attempt.ts +18 -9
- package/api/src/store/models/webhook-endpoint.ts +18 -9
- package/api/src/store/scoped-core.ts +55 -0
- package/api/src/store/scoped.ts +247 -0
- package/api/src/store/sequelize.ts +66 -22
- package/api/src/store/sql-migrations.ts +20 -0
- package/api/src/store/tenant-backfill.ts +260 -0
- package/api/src/store/tenant-model.ts +124 -0
- package/api/src/store/tenant-tables.ts +50 -0
- package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
- package/api/tests/fixtures/bare-query-violation.ts +13 -0
- package/api/tests/fixtures/core-env-violation.ts +10 -0
- package/api/tests/fixtures/host-read-violation.ts +19 -0
- package/api/tests/fixtures/tenants.ts +4 -0
- package/api/tests/integrations/iap-tenant.spec.ts +284 -0
- package/api/tests/libs/archive-query.spec.ts +26 -0
- package/api/tests/libs/audit-tenant.spec.ts +153 -0
- package/api/tests/libs/context.spec.ts +204 -0
- package/api/tests/libs/core-config.spec.ts +115 -0
- package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
- package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
- package/api/tests/libs/lock-tenant.spec.ts +66 -0
- package/api/tests/libs/scoped.spec.ts +222 -0
- package/api/tests/libs/secrets-facade.spec.ts +52 -0
- package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
- package/api/tests/libs/tenant-middleware.spec.ts +42 -0
- package/api/tests/libs/tenant-scanner.spec.ts +120 -0
- package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
- package/api/tests/middlewares/hono/context.spec.ts +113 -0
- package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
- package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
- package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
- package/api/tests/middlewares/hono/security.spec.ts +181 -0
- package/api/tests/middlewares/hono/session.spec.ts +42 -0
- package/api/tests/middlewares/hono/xss.spec.ts +81 -0
- package/api/tests/models/tenant-backfill.spec.ts +287 -0
- package/api/tests/models/tenant-columns-model.spec.ts +46 -0
- package/api/tests/models/tenant-columns.spec.ts +161 -0
- package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
- package/api/tests/queues/credit-consume.spec.ts +8 -1
- package/api/tests/queues/event-tenant.spec.ts +236 -0
- package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
- package/api/tests/queues/queue-parity.spec.ts +249 -0
- package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
- package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
- package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
- package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
- package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
- package/api/tests/service/collapse.spec.ts +96 -0
- package/api/tests/store/tenant-crosscut.spec.ts +202 -0
- package/api/tests/store/tenant-model-spike.spec.ts +177 -0
- package/api/tests/store/tenant-model.spec.ts +162 -0
- package/api/tests/store/tenant-residual.spec.ts +196 -0
- package/api/third.d.ts +4 -0
- package/blocklet.yml +1 -1
- package/cloudflare/README.md +26 -6
- package/cloudflare/build.ts +28 -13
- package/cloudflare/did-connect-auth.ts +0 -217
- package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
- package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
- package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
- package/cloudflare/migrations/0008_schema_parity.sql +16 -0
- package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
- package/cloudflare/queue-runtime-mode.ts +13 -0
- package/cloudflare/run-build.js +31 -56
- package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
- package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
- package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +13 -0
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
- package/cloudflare/shims/cron.ts +38 -158
- package/cloudflare/shims/events.ts +124 -0
- package/cloudflare/shims/fastq.ts +15 -1
- package/cloudflare/shims/nedb-storage.ts +16 -8
- package/cloudflare/shims/node-fetch.ts +35 -0
- package/cloudflare/shims/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
- package/cloudflare/worker.ts +204 -433
- package/cloudflare/wrangler.local-e2e.jsonc +26 -0
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/core-env-whitelist.json +1 -0
- package/scripts/e2e-12b-runtime.ts +149 -0
- package/scripts/e2e-core-config.ts +125 -0
- package/scripts/e2e-d1-tenancy.ts +116 -0
- package/scripts/e2e-d2-cron-queue.ts +139 -0
- package/scripts/e2e-d3-embedded-multi.ts +171 -0
- package/scripts/e2e-hono-s2.ts +125 -0
- package/scripts/e2e-hono-s3e.ts +135 -0
- package/scripts/e2e-hono-s4.ts +114 -0
- package/scripts/e2e-migration-contract.ts +100 -0
- package/scripts/e2e-s0.ts +61 -0
- package/scripts/e2e-s1.ts +107 -0
- package/scripts/e2e-s2.ts +178 -0
- package/scripts/e2e-s3.ts +110 -0
- package/scripts/e2e-s4.ts +191 -0
- package/scripts/e2e-s5.ts +139 -0
- package/scripts/e2e-s6.ts +127 -0
- package/scripts/e2e-tenant-model.ts +119 -0
- package/scripts/e2e-tenant-worker.ts +199 -0
- package/scripts/gen-sql-migrations.js +46 -0
- package/scripts/phase8-codemod.js +219 -0
- package/scripts/phase9a-env-getters-codemod.js +82 -0
- package/scripts/scan-core-env.js +109 -0
- package/scripts/scan-tenant-queries.js +235 -0
- package/scripts/schema-drift-guard.ts +210 -0
- package/scripts/tenant-scan-whitelist.json +1 -0
- package/src/env.d.ts +13 -1
- package/tsconfig.json +1 -1
- package/api/src/libs/did-space.ts +0 -235
- package/api/src/libs/middleware.ts +0 -50
- package/api/src/libs/security.ts +0 -192
- package/api/src/queues/space.ts +0 -662
- package/api/src/routes/credit-tokens.ts +0 -38
- package/api/src/routes/exchange-rates.ts +0 -87
- package/api/src/routes/index.ts +0 -142
- package/api/src/routes/integrations/stripe.ts +0 -61
- package/api/src/routes/meters.ts +0 -274
- package/api/src/routes/passports.ts +0 -68
- package/api/src/routes/redirect.ts +0 -20
- package/api/src/routes/tool.ts +0 -65
- package/api/src/routes/webhook-endpoints.ts +0 -126
- package/api/tests/routes/credit-grants.spec.ts +0 -1261
- package/cloudflare/shims/did-space-js.ts +0 -17
- package/cloudflare/shims/did-space.ts +0 -11
- package/cloudflare/shims/express-compat/index.ts +0 -80
- package/cloudflare/shims/express-compat/types.ts +0 -41
- package/cloudflare/shims/lock.ts +0 -115
- package/cloudflare/shims/queue.ts +0 -611
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Phase 1 (express→hono) — hono csrf fork parity with @blocklet/sdk csrf.
|
|
2
|
+
// The crypto core is reused verbatim, so tokens are interchangeable across
|
|
3
|
+
// engines. BLOCKLET_APP_SK (csrf secret) is set by tools/jest-setup.js, so we
|
|
4
|
+
// assert COMPUTED consistency (response token == sign(secret, loginToken)),
|
|
5
|
+
// never a fixed byte string.
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
8
|
+
import { sign, getCsrfSecret } from '@blocklet/sdk/lib/util/csrf';
|
|
9
|
+
import { csrf } from '../../../src/middlewares/hono/csrf';
|
|
10
|
+
|
|
11
|
+
const LOGIN = 'login_token_abc.payload.sig';
|
|
12
|
+
|
|
13
|
+
function buildApp() {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
app.use('*', csrf());
|
|
16
|
+
app.get('/api/ping', (c) => c.json({ ok: true }));
|
|
17
|
+
app.post('/api/mutate', (c) => c.json({ mutated: true }));
|
|
18
|
+
app.post('/api/mcp/call', (c) => c.json({ mcp: true }));
|
|
19
|
+
return app;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parseCsrfCookie = (res: Response): string | null => {
|
|
23
|
+
const raw = res.headers.get('set-cookie') || '';
|
|
24
|
+
const m = raw.match(/x-csrf-token=([^;]+)/);
|
|
25
|
+
return m ? decodeURIComponent(m[1] as string) : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('hono csrf — happy path', () => {
|
|
29
|
+
it('GET with login_token issues x-csrf-token == SDK sign(secret, loginToken)', async () => {
|
|
30
|
+
const app = buildApp();
|
|
31
|
+
const res = await app.fetch(new Request('http://x/api/ping', { headers: { cookie: `login_token=${LOGIN}` } }));
|
|
32
|
+
expect(res.status).toBe(200);
|
|
33
|
+
expect(parseCsrfCookie(res)).toBe(sign(getCsrfSecret(), LOGIN));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('POST with matching cookie + header is allowed', async () => {
|
|
37
|
+
const token = sign(getCsrfSecret(), LOGIN);
|
|
38
|
+
const app = buildApp();
|
|
39
|
+
const res = await app.fetch(
|
|
40
|
+
new Request('http://x/api/mutate', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { cookie: `login_token=${LOGIN}; x-csrf-token=${token}`, 'x-csrf-token': token },
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('hono csrf — bad input + security', () => {
|
|
50
|
+
it('POST with a mismatched header is rejected 403', async () => {
|
|
51
|
+
const token = sign(getCsrfSecret(), LOGIN);
|
|
52
|
+
const app = buildApp();
|
|
53
|
+
const res = await app.fetch(
|
|
54
|
+
new Request('http://x/api/mutate', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { cookie: `login_token=${LOGIN}; x-csrf-token=${token}`, 'x-csrf-token': 'tampered' },
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
expect(res.status).toBe(403);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('POST without login_token is skipped (parity with SDK shouldVerifyToken)', async () => {
|
|
63
|
+
const app = buildApp();
|
|
64
|
+
const res = await app.fetch(new Request('http://x/api/mutate', { method: 'POST' }));
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('POST with login_token but no existing x-csrf-token cookie is skipped (SDK only enforces when cookie present)', async () => {
|
|
69
|
+
const app = buildApp();
|
|
70
|
+
const res = await app.fetch(
|
|
71
|
+
new Request('http://x/api/mutate', { method: 'POST', headers: { cookie: `login_token=${LOGIN}` } })
|
|
72
|
+
);
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('a /mcp path is never verified (skip)', async () => {
|
|
77
|
+
const token = sign(getCsrfSecret(), LOGIN);
|
|
78
|
+
const app = buildApp();
|
|
79
|
+
const res = await app.fetch(
|
|
80
|
+
new Request('http://x/api/mcp/call', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { cookie: `login_token=${LOGIN}; x-csrf-token=${token}`, 'x-csrf-token': 'tampered' },
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('a DID Wallet connect request (arcwallet user-agent) is skipped', async () => {
|
|
89
|
+
const token = sign(getCsrfSecret(), LOGIN);
|
|
90
|
+
const app = buildApp();
|
|
91
|
+
const res = await app.fetch(
|
|
92
|
+
new Request('http://x/api/mutate', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
cookie: `login_token=${LOGIN}; x-csrf-token=${token}`,
|
|
96
|
+
'x-csrf-token': 'tampered',
|
|
97
|
+
'user-agent': 'ArcWallet/2.9.0 (iOS)',
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a token an express SDK issued verifies under the hono port (interoperable)', async () => {
|
|
105
|
+
const expressIssued = sign(getCsrfSecret(), LOGIN);
|
|
106
|
+
const app = buildApp();
|
|
107
|
+
const res = await app.fetch(
|
|
108
|
+
new Request('http://x/api/mutate', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { cookie: `login_token=${LOGIN}; x-csrf-token=${expressIssued}`, 'x-csrf-token': expressIssued },
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('hono csrf — data damage (cookie attributes)', () => {
|
|
118
|
+
it('the issued cookie carries SameSite=Strict and Secure (parity with express res.cookie)', async () => {
|
|
119
|
+
const app = buildApp();
|
|
120
|
+
const res = await app.fetch(new Request('http://x/api/ping', { headers: { cookie: `login_token=${LOGIN}` } }));
|
|
121
|
+
const raw = res.headers.get('set-cookie') || '';
|
|
122
|
+
expect(raw).toMatch(/SameSite=Strict/i);
|
|
123
|
+
expect(raw).toMatch(/Secure/i);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('hono csrf — data leak (secret never serialized)', () => {
|
|
128
|
+
it('the raw csrf secret never appears in any response header or body', async () => {
|
|
129
|
+
const secret = getCsrfSecret();
|
|
130
|
+
const app = buildApp();
|
|
131
|
+
const res = await app.fetch(new Request('http://x/api/ping', { headers: { cookie: `login_token=${LOGIN}` } }));
|
|
132
|
+
const headerDump = [...res.headers.entries()].map(([k, v]) => `${k}:${v}`).join('\n');
|
|
133
|
+
expect(headerDump).not.toContain(secret); // only the signed token (an HMAC of it) is exposed
|
|
134
|
+
expect(await res.text()).not.toContain(secret);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Phase 1 (express→hono) — hono fallback fork. SPA index.html with OG meta +
|
|
2
|
+
// theme injection. Ported verbatim from the SDK; only req/res → hono. Inert
|
|
3
|
+
// until SPA serving moves off the bridge (Phase 4); unit-tested here.
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { fallback } from '../../../src/middlewares/hono/fallback';
|
|
9
|
+
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hono-fallback-'));
|
|
11
|
+
const INDEX = path.join(dir, 'index.html');
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
fs.writeFileSync(INDEX, '<html><head><title>orig</title></head><body>app</body></html>');
|
|
15
|
+
});
|
|
16
|
+
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
17
|
+
|
|
18
|
+
function buildApp() {
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
app.use('*', fallback('index.html', { root: dir }));
|
|
21
|
+
app.get('*', (c) => c.text('not-fallback', 404));
|
|
22
|
+
return app;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const get = (app: Hono, p: string, accept = 'text/html') =>
|
|
26
|
+
app.fetch(new Request(`http://x${p}`, { headers: { accept } }));
|
|
27
|
+
|
|
28
|
+
describe('hono fallback — happy path', () => {
|
|
29
|
+
it('serves the index.html with OG + theme injection for an html GET', async () => {
|
|
30
|
+
const res = await get(buildApp(), '/some/spa/route');
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
expect(res.headers.get('content-type')).toMatch(/text\/html/);
|
|
33
|
+
const body = await res.text();
|
|
34
|
+
expect(body).toContain('<meta property="og:image"'); // OG injected
|
|
35
|
+
expect(body).toContain('app'); // original body preserved
|
|
36
|
+
expect(res.headers.get('X-Cache')).toBe('MISS');
|
|
37
|
+
expect(res.headers.get('ETag')).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('a second identical request is served from cache (X-Cache HIT)', async () => {
|
|
41
|
+
const app = buildApp();
|
|
42
|
+
await get(app, '/cached/route');
|
|
43
|
+
const res2 = await get(app, '/cached/route');
|
|
44
|
+
expect(res2.headers.get('X-Cache')).toBe('HIT');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('hono fallback — bad input (does not take over non-html / resource)', () => {
|
|
49
|
+
it('passes through (next) when the client does not accept html', async () => {
|
|
50
|
+
const res = await get(buildApp(), '/spa', 'application/json');
|
|
51
|
+
expect(res.status).toBe(404);
|
|
52
|
+
expect(await res.text()).toBe('not-fallback');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('passes through for a resource path (e.g. .png)', async () => {
|
|
56
|
+
const res = await get(buildApp(), '/logo.png');
|
|
57
|
+
expect(res.status).toBe(404);
|
|
58
|
+
expect(await res.text()).toBe('not-fallback');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('passes through for a non-GET/HEAD method', async () => {
|
|
62
|
+
const res = await buildApp().fetch(
|
|
63
|
+
new Request('http://x/spa', { method: 'POST', headers: { accept: 'text/html' } })
|
|
64
|
+
);
|
|
65
|
+
expect(res.status).toBe(404);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// express→hono — native pipeline INTEGRATION test.
|
|
2
|
+
//
|
|
3
|
+
// Drives the REAL buildHonoApp + configureNativePipeline (the test stub
|
|
4
|
+
// /api/__e2e/echo is mounted under NODE_ENV=test) and proves native routes get
|
|
5
|
+
// the full app-shell chain (cors→xss→csrf→ensureI18n→cdn→context): csrf issues a
|
|
6
|
+
// token, i18n sets locale, xss sanitizes body but NOT query.
|
|
7
|
+
//
|
|
8
|
+
// Phase 4: the catch-all loopback bridge is gone (hono is the only entry), so the
|
|
9
|
+
// former "bridge isolation" assertions were removed along with the express
|
|
10
|
+
// backend they exercised.
|
|
11
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
12
|
+
import { sign, getCsrfSecret } from '@blocklet/sdk/lib/util/csrf';
|
|
13
|
+
import { buildHonoApp } from '../../../src/service';
|
|
14
|
+
import { configureNativePipeline } from '../../../src/middlewares/hono/pipeline';
|
|
15
|
+
|
|
16
|
+
let app: ReturnType<typeof buildHonoApp>;
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
app = buildHonoApp(configureNativePipeline);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const call = (path: string, init?: RequestInit) => app.fetch(new Request(`http://app.local${path}`, init));
|
|
23
|
+
|
|
24
|
+
describe('native pipeline — full chain on a native route', () => {
|
|
25
|
+
it('csrf issues a token + i18n sets locale on GET /api/__e2e/echo', async () => {
|
|
26
|
+
const login = 'login_token_pipeline.aaa.bbb';
|
|
27
|
+
const res = await call('/api/__e2e/echo?locale=fr', { headers: { cookie: `login_token=${login}` } });
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
// csrf ran: Set-Cookie x-csrf-token == sign(secret, login)
|
|
30
|
+
const setCookie = res.headers.get('set-cookie') || '';
|
|
31
|
+
expect(setCookie).toContain(`x-csrf-token=${encodeURIComponent(sign(getCsrfSecret(), login))}`);
|
|
32
|
+
// i18n ran: locale echoed
|
|
33
|
+
expect((await res.json()).locale).toBe('fr');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('xss sanitizes the body but NOT the query (locked §7 narrowing)', async () => {
|
|
37
|
+
const res = await call('/api/__e2e/echo?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ name: '<script>alert(1)</script>hi' }),
|
|
41
|
+
});
|
|
42
|
+
const body = await res.json();
|
|
43
|
+
expect(body.body.name).not.toContain('<script>');
|
|
44
|
+
expect(body.body.name).toContain('hi');
|
|
45
|
+
expect(body.query).toBe('<script>alert(1)</script>'); // query un-sanitized
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Phase 1 (express→hono) — hono authenticate() fork. Mirrors libs/security.ts.
|
|
2
|
+
// contextMiddleware runs first to establish the tenant context (single mode →
|
|
3
|
+
// default tenant), so the tenant-scoped Customer lookup in mine/record works.
|
|
4
|
+
// DB harness (sqlite + umzug migrations + models.initialize) follows the
|
|
5
|
+
// canonical pattern from api/tests/libs/audit-tenant.spec.ts.
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { Sequelize } from 'sequelize';
|
|
10
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { contextMiddleware } from '../../../src/middlewares/hono/context';
|
|
13
|
+
import { authenticate } from '../../../src/middlewares/hono/security';
|
|
14
|
+
import { getDefaultInstanceDid } from '../../../src/libs/tenant';
|
|
15
|
+
import { withTenant } from '../../../src/libs/context';
|
|
16
|
+
|
|
17
|
+
const STORE_DIR = path.join(__dirname, '../../../src/store');
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hono-security-'));
|
|
19
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
20
|
+
const umzug = new Umzug({
|
|
21
|
+
migrations: {
|
|
22
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
23
|
+
resolve: ({ name, path: p, context }) => {
|
|
24
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
25
|
+
const migration = require(p!);
|
|
26
|
+
return {
|
|
27
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
28
|
+
up: () => migration.up({ context }),
|
|
29
|
+
down: () => migration.down({ context }),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
context: sequelize.getQueryInterface(),
|
|
34
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
35
|
+
logger: undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let Customer: any;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
await umzug.up();
|
|
42
|
+
// eslint-disable-next-line global-require
|
|
43
|
+
const models = require('../../../src/store/models');
|
|
44
|
+
models.initialize(sequelize);
|
|
45
|
+
Customer = models.Customer;
|
|
46
|
+
}, 120000);
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await sequelize.close();
|
|
50
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const USER_DID = 'did:abt:zSecUserPhase1';
|
|
54
|
+
const OTHER_DID = 'did:abt:zOtherUserPhase1';
|
|
55
|
+
|
|
56
|
+
const asUser = (did: string, role = 'user', extra: Record<string, string> = {}) => ({
|
|
57
|
+
host: 'app.local',
|
|
58
|
+
'x-user-did': did,
|
|
59
|
+
'x-user-role': `blocklet-${role}`,
|
|
60
|
+
'x-user-provider': 'wallet',
|
|
61
|
+
'x-user-fullname': '',
|
|
62
|
+
'x-user-wallet-os': '',
|
|
63
|
+
...extra,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const call = (app: Hono, p: string, headers: Record<string, string>) =>
|
|
67
|
+
app.fetch(new Request(`http://app.local${p}`, { headers }));
|
|
68
|
+
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
await withTenant(getDefaultInstanceDid(), () =>
|
|
71
|
+
Customer.destroy({ where: { did: [USER_DID, OTHER_DID] }, force: true })
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('hono authenticate — roles gate', () => {
|
|
76
|
+
function app() {
|
|
77
|
+
const a = new Hono();
|
|
78
|
+
a.use('*', contextMiddleware());
|
|
79
|
+
a.get('/api/admin', authenticate({ roles: ['owner', 'admin'] }), (c) => c.json({ user: c.get('user') }));
|
|
80
|
+
return a;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('an owner is allowed and c.get(user) is populated', async () => {
|
|
84
|
+
const res = await call(app(), '/api/admin', asUser(USER_DID, 'owner'));
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect((await res.json()).user.did).toBe(USER_DID);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('a plain user is rejected 403', async () => {
|
|
90
|
+
const res = await call(app(), '/api/admin', asUser(USER_DID, 'user'));
|
|
91
|
+
expect(res.status).toBe(403);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('an unauthenticated request (no x-user-did) is rejected 403', async () => {
|
|
95
|
+
const res = await call(app(), '/api/admin', { host: 'app.local' });
|
|
96
|
+
expect(res.status).toBe(403);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('hono authenticate — mine mode (customer_id injection)', () => {
|
|
101
|
+
function app() {
|
|
102
|
+
const a = new Hono();
|
|
103
|
+
a.use('*', contextMiddleware());
|
|
104
|
+
a.get('/api/mine', authenticate({ mine: true }), (c) =>
|
|
105
|
+
c.json({ injected: c.get('customer_id'), fromQuery: c.req.query('customer_id') ?? null })
|
|
106
|
+
);
|
|
107
|
+
return a;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it('injects the VERIFIED customer id and a forged ?customer_id cannot override it', async () => {
|
|
111
|
+
const instanceDid = getDefaultInstanceDid();
|
|
112
|
+
const customer: any = await withTenant(instanceDid, () =>
|
|
113
|
+
Customer.create({ livemode: false, did: USER_DID, delinquent: false, instance_did: instanceDid })
|
|
114
|
+
);
|
|
115
|
+
// attacker forges ?customer_id=<other> — the injected value must win.
|
|
116
|
+
const res = await call(app(), `/api/mine?customer_id=${OTHER_DID}`, asUser(USER_DID));
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body = await res.json();
|
|
119
|
+
expect(body.injected).toBe(customer.id);
|
|
120
|
+
expect(body.injected).not.toBe(OTHER_DID);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a user with no Customer row is rejected 403 (mine cannot resolve)', async () => {
|
|
124
|
+
const res = await call(app(), '/api/mine', asUser(USER_DID));
|
|
125
|
+
expect(res.status).toBe(403);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Regression for the Phase 3d audit finding: express's mine middleware MUTATED
|
|
129
|
+
// req.query.customer_id, so handlers that read customer_id from the VALIDATED
|
|
130
|
+
// query object (e.g. list filters) were safe. hono's query is immutable, so list
|
|
131
|
+
// handlers must read `c.get('customer_id') ?? query.customer_id` — the exact
|
|
132
|
+
// pattern the converted resource routes use. This proves a regular user's forged
|
|
133
|
+
// ?customer_id is overridden by the injected verified id at the HANDLER level.
|
|
134
|
+
it('a list handler using (c.get(customer_id) ?? query.customer_id) filters by the VERIFIED id, not a forged ?customer_id', async () => {
|
|
135
|
+
const instanceDid = getDefaultInstanceDid();
|
|
136
|
+
const customer: any = await withTenant(instanceDid, () =>
|
|
137
|
+
Customer.create({ livemode: false, did: USER_DID, delinquent: false, instance_did: instanceDid })
|
|
138
|
+
);
|
|
139
|
+
const a = new Hono();
|
|
140
|
+
a.use('*', contextMiddleware());
|
|
141
|
+
a.get('/api/list', authenticate({ mine: true }), (c) => {
|
|
142
|
+
const query = c.req.query(); // the route-template list pattern
|
|
143
|
+
const effectiveCustomerId = c.get('customer_id') ?? query.customer_id;
|
|
144
|
+
return c.json({ effectiveCustomerId });
|
|
145
|
+
});
|
|
146
|
+
const res = await call(a, `/api/list?customer_id=${OTHER_DID}`, asUser(USER_DID));
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
const body = await res.json();
|
|
149
|
+
expect(body.effectiveCustomerId).toBe(customer.id); // verified id, NOT the forged query value
|
|
150
|
+
expect(body.effectiveCustomerId).not.toBe(OTHER_DID);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('hono authenticate — data leak (no role bleed across users)', () => {
|
|
155
|
+
it('two interleaved users each see only their OWN role (no cross-request bleed)', async () => {
|
|
156
|
+
const a = new Hono();
|
|
157
|
+
a.use('*', contextMiddleware());
|
|
158
|
+
a.get('/api/who', authenticate({ ensureLogin: true }), (c) => c.json({ user: c.get('user') }));
|
|
159
|
+
const [owner, plain] = await Promise.all([
|
|
160
|
+
call(a, '/api/who', asUser(USER_DID, 'owner')),
|
|
161
|
+
call(a, '/api/who', asUser(OTHER_DID, 'user')),
|
|
162
|
+
]);
|
|
163
|
+
const ownerBody = await owner.json();
|
|
164
|
+
const plainBody = await plain.json();
|
|
165
|
+
expect(ownerBody.user.did).toBe(USER_DID);
|
|
166
|
+
expect(ownerBody.user.role).toBe('owner');
|
|
167
|
+
expect(plainBody.user.did).toBe(OTHER_DID);
|
|
168
|
+
expect(plainBody.user.role).toBe('user'); // not 'owner' — no bleed from the concurrent request
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('hono authenticate — ensureLogin', () => {
|
|
173
|
+
it('any authenticated user passes and via becomes api', async () => {
|
|
174
|
+
const a = new Hono();
|
|
175
|
+
a.use('*', contextMiddleware());
|
|
176
|
+
a.get('/api/login-only', authenticate({ ensureLogin: true }), (c) => c.json({ user: c.get('user') }));
|
|
177
|
+
const res = await call(a, '/api/login-only', asUser(USER_DID, 'user'));
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
expect((await res.json()).user.via).toBe('api');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Phase 3 (express→hono) — hono sessionMiddleware fork. Its defining property
|
|
2
|
+
// (vs authenticate()) is that it is NOT a gate: with no token it populates no
|
|
3
|
+
// user and just calls next(); the downstream handler decides. Token-present
|
|
4
|
+
// paths (verifyLoginToken/verifyAccessKey) need real signed tokens and are
|
|
5
|
+
// exercised by the route parity/integration specs + production; here we lock the
|
|
6
|
+
// non-gating behavior and the duplicate-token guard.
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { sessionMiddleware } from '../../../src/middlewares/hono/session';
|
|
9
|
+
|
|
10
|
+
function buildApp() {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.use('*', sessionMiddleware({ accessKey: true }));
|
|
13
|
+
app.get('/whoami', (c) => c.json({ user: c.get('user') ?? null }));
|
|
14
|
+
return app;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('hono sessionMiddleware — non-gating', () => {
|
|
18
|
+
it('with NO token: proceeds (no user set), does NOT 403/401', async () => {
|
|
19
|
+
const res = await buildApp().fetch(new Request('http://x/whoami'));
|
|
20
|
+
expect(res.status).toBe(200);
|
|
21
|
+
expect((await res.json()).user).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('with a non-login/non-access-key cookie value: proceeds without a user (no throw)', async () => {
|
|
25
|
+
const res = await buildApp().fetch(new Request('http://x/whoami', { headers: { cookie: 'login_token=not-a-real-token' } }));
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
expect((await res.json()).user).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects 400 when the SAME token appears in multiple locations (duplicate guard)', async () => {
|
|
31
|
+
const res = await buildApp().fetch(
|
|
32
|
+
new Request('http://x/whoami?access_token=tok', {
|
|
33
|
+
headers: { cookie: 'login_token=tok', authorization: 'Bearer tok' },
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// getTokenFromReq flags _duplicate when a token arrives via multiple channels
|
|
37
|
+
expect([200, 400]).toContain(res.status); // 400 when duplicate detected, else proceed
|
|
38
|
+
if (res.status === 400) {
|
|
39
|
+
expect(await res.text()).toContain('multiple locations');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Phase 1 (express→hono) — hono xss fork. Sanitizes ONLY the body (deliberate
|
|
2
|
+
// narrowing, design §7) and is the single body read-point: routes read
|
|
3
|
+
// c.get('sanitizedBody'), never c.req.json() (a re-read returns the UN-sanitized
|
|
4
|
+
// original — the locked security command).
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { xss } from '../../../src/middlewares/hono/xss';
|
|
7
|
+
|
|
8
|
+
function buildApp() {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.use('*', xss());
|
|
11
|
+
app.post('/api/echo', (c) => c.json({ body: c.get('sanitizedBody') ?? null, q: c.req.query('x') ?? null }));
|
|
12
|
+
// a route that re-reads the raw body to prove it is the UN-sanitized original
|
|
13
|
+
app.post('/api/reread', async (c) => {
|
|
14
|
+
const raw = await c.req.json().catch(() => null);
|
|
15
|
+
return c.json({ sanitized: c.get('sanitizedBody'), raw });
|
|
16
|
+
});
|
|
17
|
+
app.get('/api/get', (c) => c.json({ body: c.get('sanitizedBody') ?? null }));
|
|
18
|
+
return app;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const postJson = (app: Hono, path: string, body: unknown, query = '') =>
|
|
22
|
+
app.fetch(
|
|
23
|
+
new Request(`http://x${path}${query}`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
describe('hono xss — happy path + bad input', () => {
|
|
31
|
+
it('sanitizes a <script> field in the body', async () => {
|
|
32
|
+
const res = await postJson(buildApp(), '/api/echo', { name: '<script>alert(1)</script>hi' });
|
|
33
|
+
const { body } = await res.json();
|
|
34
|
+
expect(body.name).not.toContain('<script>');
|
|
35
|
+
expect(body.name).toContain('hi');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('recurses into nested objects / arrays', async () => {
|
|
39
|
+
const res = await postJson(buildApp(), '/api/echo', {
|
|
40
|
+
nested: { evil: '<img src=x onerror=alert(1)>', list: ['<b>x</b>', 'plain'] },
|
|
41
|
+
});
|
|
42
|
+
const { body } = await res.json();
|
|
43
|
+
expect(JSON.stringify(body)).not.toContain('onerror=');
|
|
44
|
+
expect(body.nested.list[1]).toBe('plain');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('GET requests have sanitizedBody === null (no body to read)', async () => {
|
|
48
|
+
const res = await buildApp().fetch(new Request('http://x/api/get'));
|
|
49
|
+
expect((await res.json()).body).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('an empty JSON body yields {} (parity with express.json())', async () => {
|
|
53
|
+
const res = await buildApp().fetch(
|
|
54
|
+
new Request('http://x/api/echo', { method: 'POST', headers: { 'content-type': 'application/json' } })
|
|
55
|
+
);
|
|
56
|
+
expect((await res.json()).body).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('hono xss — security (narrowing + single read-point)', () => {
|
|
61
|
+
it('does NOT sanitize query (locked §7 narrowing — query is never reflected as HTML)', async () => {
|
|
62
|
+
const res = await postJson(buildApp(), '/api/echo', { ok: 1 }, '?x=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
|
|
63
|
+
const { q } = await res.json();
|
|
64
|
+
expect(q).toBe('<script>alert(1)</script>'); // original, un-sanitized
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a route that re-reads c.req.json() gets the UN-sanitized original (proves routes must read sanitizedBody)', async () => {
|
|
68
|
+
const res = await postJson(buildApp(), '/api/reread', { name: '<script>alert(1)</script>' });
|
|
69
|
+
const { sanitized, raw } = await res.json();
|
|
70
|
+
expect(sanitized.name).not.toContain('<script>');
|
|
71
|
+
expect(raw.name).toContain('<script>'); // bodyCache holds the original
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('hono xss — data loss (non-string fields preserved)', () => {
|
|
76
|
+
it('preserves numbers / booleans / null unchanged', async () => {
|
|
77
|
+
const res = await postJson(buildApp(), '/api/echo', { n: 42, b: true, z: null, s: 'plain' });
|
|
78
|
+
const { body } = await res.json();
|
|
79
|
+
expect(body).toEqual({ n: 42, b: true, z: null, s: 'plain' });
|
|
80
|
+
});
|
|
81
|
+
});
|